Principle 1: It's a Black Box

Coupling tests with implementation

One of the main reasons our tests do not withstand refactoring is that we tend to couple tests to the actual implementation.

Think about it: if you use methods from within your module or class in a test, then as soon as you need to change your code your tests will stop compiling (or break in runtime if using dynamic language). Examples could be:

  • change how some structures are used internally (for example a method will be removed as not needed anymore)
  • change how objects/functions interact with each other (some will start to cooperate, some will be isolated)

To ensure your tests assist during the development and refactoring of the system, we need to decouple them from the implementation - in other words, your tests cannot rely on the internal implementation and structure of your class or module.

The Black Box Pattern: A Behavioral Approach

The Black Box pattern offers a solution by shifting the focus of testing from implementation details to system behavior.

The idea is simple: your system under test is a box, and you know nothing about its internals. It’s just an input in and output out.

img_2.png img_2.png

From programming perspective the only things you can do with a black box are:

  • creating a black box (module/class/function)
  • use Black Box API to set up your scenario
  • use Black Box API to execute operation under test
  • use Black Box API to verify that your scenario works as expected

In Black Box testing, the internal details of the system are unknown to the tester. Instead, tests are designed based on the expected behavior of the system in response to various inputs and functions executed.

This approach ensures that tests remain valid regardless of changes to the internal structure of the code.

Test example with Black Box

There can be nothing better to describe how Black Box test could work than … A Shopping Cart! (Don’t worry, we’ll be adding more ‘real life’ examples in the future).

Shopping cart basic scenario is simple: when I add an item to shopping cart and I retrieve my shopping cart - the item is there with expected quantity.

img_2.png img_2.png

@Test
public void addingToShoppingCart() {
 //given
 var shoppingCart = new ShoppingCart();
 var item = new Item(UUID.randomUUID().toString());

 //when
 shoppingCart.addToCart(item, 1);

 //then
 Assertions.assertEquals(List.of(item), shoppingCart.getItems());
}

But what is happening underneath?

Well your test doesn’t know that, and that’s perfect! You can change your ShoppingCart implementation from a simple list to a map. From a single object to a hundred involved in implementation. You can or even change a database implementation of your module - and your test will still pass if you got the implementation right.

You don’t worry here about all the complexity that sits behind the ShoppingCart.addToCart(...) method - you just care about the behavior of the system from your API client’s perspective.

You can think about Black Box pattern in tests as you’d think about encapsulation in your production code. You don’t want to expose your internal structure to the outside world, as it would make your code more fragile and harder to maintain. The same applies to tests in a form of Black Box testing.

Final thoughts

The Black Box pattern is one of fundamentals to decouple your tests and implementation details.

By decoupling your test’s code from the internal structure of your production code, this pattern enhances the resilience and stability of tests => which lets you refactor code with confidence.

Embracing this pattern requires a shift in mindset, but the benefits in terms of test robustness and maintainability are well worth the effort.

Let’s jump into the second principle!