Rust

A Letter to Rust Users: Don’t Test Implementation Details (Unit vs. Integration Tests)

Don't test implementation details using unit tests in Rust, as a developer you should be testing behavior.

Written by Gregory Gaines
6 min read
0 views

Table of Contents

Howdy Reader 👋🏽

A short introduction, I'm a software engineer that's worked on huge enterprise systems at @Google, and a rule I've always followed is when unit testing, "Don't test implementation details". If you have to, you've designed your code wrong. Let's go over how Rust defines unit and integration tests because it differs from the traditional definitions.

Terminology

In Rust, there are no classes, only structs, but I will be using them interchangeably and writing pseudo code for easier consumption for readers.

Rust Unit and Integration Tests Definition 🛠️

Traditionally, a unit test means testing a unit of code like a class, and an integration test focuses on testing between layers like verifying an API call writes the correct data to a database.

In Rust, a unit test is described as a test of the "internal details" of a class like its private functions with access to its private variables because you write the tests inside the class itself, so it has access to the internals.

For integration tests, Rust defines it as a test that can only call the public API aka public functions of a class since you write them outside of the class you are testing. Pretty much how unit testing is for any other language outside of Rust.

Keep these definitions for Rust in mind, I don't want to see any comments with this confusion or I'm shaming you publicly. As programmers, we should not be testing the internal details of a class.

Why You Shouldn't Test Implementation Details 🚫

Rust unit tests allow developers to test the internal details of their classes. When writing tests you should focus on testing behavior NOT implementation. We only care about the results not how we got there.

Here's an example, it's exaggerated of course.

Java
CalculatorV1.rs
1// Adding using bitwise and a bunch of private implementation functions 2public func add(a: int, b: int) -> int { 3 while (isNotEqualToZero(b)) { 4 carry = calculateCarryVal(a, b); 5 a = calculateSum(a, b); 6 b = getCarryVal(carry); 7 } 8 9 return a; 10} 11 12private func isNotEqualToZero(num: int) -> bool { 13 return num != 0; 14} 15 16private func calculateCarryVal(a: int, b: int) -> int { 17 return a & b; 18} 19 20private func calculateSum(a: int, b: int) -> int { 21 return a ^ b; 22} 23 24private func getCarryVal(carry: int) -> int { 25 return carry << 1; 26}

Above we calculate the addition of two numbers, in Rust, we can write unit tests for the internal private functions since the tests sit next to the code.

Java
CalculatorV1.rs
1// Public Api 2public func add(a: int, b: int) -> int { 3... 4 5// Private implementation functions 6... 7private func getCarryVal(carry: int) -> int { 8 return carry << 1; 9} 10... 11 12// Unit tests sitting next to code and testing implementation details. 13#[test] 14public func test_getCarryVal() { 15 int carry = 4; 16 assert(getCarryVal(4), 1); 17} 18 19#[test] 20public func test_calculateSum() { 21 int a = 1; 22 int b = 2; 23 assert(calculateSum(a, b), 2); 24}

For an integration test, we only care about the behavior of the public API, so import the public function and test it.

Java
CalculatorIntegrationTest.rs
1... 2import add; 3 4#[test] 5public func test_add() { 6 int a = 6; 7 int b = 10; 8 assert(add(a, b), 16); 9}

This is easy and verifies the expected behavior of the class. Let's say down the road we want to change the implementation details of our calculator to a new implementation called CalculatorV2.

Java
CalculatorV2.rs
1// Adding using loop 2public func add(a: int, b: int) -> int { 3 int result = b; 4 while (isGreaterThanZero(a)) { 5 result++; 6 a--; 7 } 8 return result; 9} 10 11private func isGreaterThanZero(num: int) -> bool { 12 return num > 0; 13}

After the update, all the internal "unit tests" you wrote are now obsolete and must be removed. Now you have to write new ones:

Java
CalculatorV2.rs
1... 2private func isGreaterThanZero(num: int) -> bool { 3... 4 5// New unit test for implementation details 6#[test] 7public func test_isGreaterThanZero() { 8 int a = 3; 9 assert(isGreaterThanZero(a), true); 10}

This is one reason why you shouldn't test implementation details. They are flaky and potentially updated for every class change. They aren't very reliable because you are testing them against parameters outside of your public API, and you may be exposing them to conditions it shouldn't operate against which leads to writing unnecessary code instead of focusing on conditions from the public API.

For our integration tests, we don't have to touch anything because we are testing the expected behavior only.

Java
CalculatorIntegrationTest.rs
1import add; 2 3... 4// No need to change anything 5#[test] 6public func test_add() { 7 int a = 6; 8 int b = 10; 9 assert(add(a, b), 16); 10}

As for those who think you should test implementation details, take our CalculatorV1. What if another engineer refactored it without any private functions?

Java
CalculatorV1.rs
1// Adding using bitwise without private functions 2public func add(a: int, b: int) -> int { 3 while (b != 0) { 4 carry = a & b; 5 a = a ^ b; 6 b = carry << 1; 7 } 8 9 return a; 10}

In this example, we have no internal private functions to test. This is why you shouldn't care about internal details. We can refactor our code to remove all private functions, rewrite them, or combine them all into one function. One thing stays the same, we still have the public API with an expected behavior no matter how the function is implemented.

Do you see why testing internal details is bad? We shouldn't care about any of that, only the behavior of the public API which is the most important should be our main priority, not thinking of all the ways we can break an internal private function that can be refactored or removed the next day.

If your internal functions are so complex that they need tests, then again, your code needs to be refactored or you need to abstract functionality.

Bonus Content 💰

I already see these questions being asked in the comments so I'll answer them here. If you do, I'll shame you publicly.

  1. What about Mocks? No mocks are not testing implementation details. The boundary between your code and external dependencies is not implementation details.

  2. What about Stubs? Again, not testing implementation details for the reason above. They can also be used for avoiding introducing complexity into tests.

Final Thoughts 💭

You should be testing the behavior of your class, not the internal details. Tests that focus on internal details are flaky and unreliable compared to tests that focus on testing behavior. Rust even acknowledges the debate of testing internal details, but exposes the functionality anyway. At the end of the day, the public API that exposes a behavior matters the most and that's what should be tested.

About the Author 👨🏾‍💻

I'm Gregory Gaines, a simple software engineer at Google that writes whatever's on his mind. If you want more content, follow me on Twitter at @GregoryAGaines.

If you have any questions, hit me up on Twitter (@GregoryAGaines); we can talk about it.

Thanks for reading!

About the author.

I'm Gregory Gaines, a software engineer that loves blogging, studying computer science, and reverse engineering.

I'm currently employed at Google; all opinions are my own.

Ko-fi donationsBuy Me a CoffeeBecome a Patron
Gregory Gaines

You may also like.

Comments.

Get updates straight to your mailbox!

Get the latest blog updates about programming and the industry ins and outs for free!

You have my spam free, guarantee. 🥳

Subscribe