DEV Community

Cover image for A Letter to Rust Users: Don’t Test Implementation Details (Unit vs. Integration Tests)
Gregory Gaines
Gregory Gaines

Posted on • Updated on • Originally published at gregorygaines.com

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

Table Of Contents


Howdy Reader 👋🏽

A short introduction, I'm a software engineer that's worked on huge enterprise systems at [REDACTED], 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.

CalculatorV1.rs

// Adding using bitwise and a bunch of private implementation functions
public func add(a: int, b: int) -> int {
  while (isNotEqualToZero(b)) {
    carry = calculateCarryVal(a, b);
    a = calculateSum(a, b);
    b = getCarryVal(carry);
  }

  return a;
}

private func isNotEqualToZero(num: int) -> bool {
  return num != 0;
}

private func calculateCarryVal(a: int, b: int) -> int {
  return a & b;
}

private func calculateSum(a: int, b: int) -> int {
  return a ^ b;
}

private func getCarryVal(carry: int) -> int {
  return carry << 1;
}
Enter fullscreen mode Exit fullscreen mode

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.

CalculatorV1.rs

// Public Api
public func add(a: int, b: int) -> int {
...

// Private implementation functions
...
private func getCarryVal(carry: int) -> int {
  return carry << 1;
}
...

// Unit tests sitting next to code and testing implementation details.
#[test]
public func test_getCarryVal() {
 int carry = 4;
 assert(getCarryVal(4), 1);
}

#[test]
public func test_calculateSum() {
 int a = 1;
 int b = 2;
 assert(calculateSum(a, b), 2);
}
Enter fullscreen mode Exit fullscreen mode

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

Calculator_Test.rs

...
import add;

#[test]
public func test_add() {
 int a = 6;
 int b = 10;
 assert(add(a, b), 16);
}
Enter fullscreen mode Exit fullscreen mode

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.

CalculatorV2.rs

// Adding using loop
public func add(a: int, b: int) -> int {
  int result = b;
  while (isGreaterThanZero(a)) {
   result++;
   a--;
  }
  return result;
}

private func isGreaterThanZero(num: int) -> bool {
  return num > 0;
}
Enter fullscreen mode Exit fullscreen mode

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

CalculatorV2.rs

...
private func isGreaterThanZero(num: int) -> bool {
...

// New unit test for implementation details
#[test]
public func test_isGreaterThanZero() {
 int a = 3;
 assert(isGreaterThanZero(a), true);
}
Enter fullscreen mode Exit fullscreen mode

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.

Calculator_Test.rs

import add;

...
// No need to change anything
#[test]
public func test_add() {
 int a = 6;
 int b = 10;
 assert(add(a, b), 16);
}
Enter fullscreen mode Exit fullscreen mode

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

CalculatorV1.rs

// Adding using bitwise without private functions
public func add(a: int, b: int) -> int {
  while (b != 0) {
    carry = a & b; 
    a = a ^ b;
    b = carry << 1;
  }

  return a;
}
Enter fullscreen mode Exit fullscreen mode

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 [REDACTED] 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!

Top comments (2)

Collapse
 
pavonz profile image
Andrea Pavoni

Excuse me, not to be pedantic, but I’ve seen you used some wrong Rust keywords, macros and unconventional file naming, in particular:

  • public func should be pub fn

  • private keyword doesn’t exists, because every member of a struct is private by default if pub is not specified.

  • not 100% sure about this detail, but to assert equality usually there’s the macro assert_eq!

  • file naming should be snake_case, not Like_This

One final, pedantic, note: testing private implementation details is almost always bad practice regardless of language/technology, not just Rust.

That said, unit tests and integration tests have different scopes and goals and aren’t related with the private implementation details. Testing the public behaviour/interface class/object that does something, is a unit test, and it checks that that object is behaving like expected.
Integration tests, on the other hand, check that a part of the system, composed by many single objects (unit tested on their own), are behaving as expected when working together. I do concede, however, that sometimes some unit/integration tests might overlap each other, in those cases it’s up to the dev how to handle it.

Collapse
 
gregorygaines profile image
Gregory Gaines

Hello Andrea, I made it know that I would be using pseudo code for easier consumption.

I’m glad the point of the article got across. Nice points!