Test behaviour, not implementation

Here is the cherry at the top, last missing piece of the puzzle and the Holy Grail of writing useful tests.

Implementing this 4th rule on the top of the first 3 principles will make you a testing rockstar, writing tests that are:

  • easy to read and develop
  • resilient to implementation changes when functionality doesn’t change
  • bring great confidence during deployments (up to the point where you no longer need manual tests)
  • helps to use 3 principles in practice
  • decouples your tests from implementation

This rule is:

Test behaviour, not implementation

It’s a little bit different from the previous three principles, as it’s not about the structure of your tests, but about the mindset of what your tests should cover.

Why?

Consider this example: If your Shopping Cart unit needs to implement a new feature, like adding a discount to the cart, you might decide to change your current implementation completely.

But all previously implemented functionalities should work exactly the same way if no discount code is applied.

It doesn’t matter whether you create a new DiscountModule or you just add a function that takes a User and Cart.

Your previous tests without any applied discount should still pass after the discount code implementation is completed.

But if your tests are focused on implementation, adding discount code most probably mean that you need to rewrite them all - to include more mocks, or change how your tests are set up.

How to think about behaviour

That’s simple yet not easy. Try thinking about your system as it’s viewed by its user - and how they would check if their action worked or not.

How user could verify that he added the item to the cart successfully? By retrieving their shopping cart and checking if the new item is there in good quantity.

This rule drives you to write tests focusing on “how the system behaves” and “what the system does”, rather than “how the system does it”.

The how is the implementation, that is willing to change, but the behaviour of the system stays the same, doesn’t matter how your code looks like.

who is your client

Every system has a client. If you’re providing an API, whether it’s queue consumer, or REST API, there is “somebody” who will use it. That’s your client, and try to see your tests from your client’s perspective.

what is behaviour?

Actually the best way to express system behaviour is to think in terms of short use cases important for your client. Here are some examples from multiple domains to compare behaviour-focused with implementation-focused tests:

domain behaviour-focused implementation-focused
ecommerce When a customer adds an item to cart, when customer look into the cart, the item is there in the added quantity When customer adds an item to cart, and the Inventory module mock will return that item when needed, I can see that the quantity of given product in Postgres Database under given cartId key is incremented by 1
social platform when a new user has set their account as private, they cannot be contacted by people who are not their friends when I change a “isPrivate” flag in the UserRepository, and UserFriendsRepository mock returns no friends, I try to send a message to that user from another account, then the message cannot be delivered
banking user cannot withdraw more money than they have on their balance account when I set account balance in Database to 10$ and user tries to withdraw 11$, then the system throws an exception.

how to spot if your test is not about behaviour but it’s about implementation

Here’s some checks you can make to see if your tests are written with a behaviour style in mind:

  • my test can be read from a user perspective - who is not allowed to see my code inside except the API
  • my test don’t know about the internal structure of my code
  • my test know about which components and how many times are called (I don’t use mocks)
  • my test scenario setup does not use any low level code directly (like directly setting up some properties in Database, or verifying at Repository level that entity has been saved)
  • my test doesn’t know how this feature has been implemented

If you find yourself answering “yes” to any of these questions, try to think in terms of user stories/behaviours or traits of your system. Applying previous 3 principles will help you to achieve that.

Isn’t that just BDD?

Yes, this one is actually a lightweight approach of Behavior Driven Development, but applied at mindset level, without forcing any specific tools or libraries. Just using the best practices that BDD promotes. It’s about the mindset, but joining that with the previous 3 principles will maximize the benefits of your tests.

how it connects with 3 previous principles?

  • When testing behaviour, it’s natural to treat your system as a Black Box that’s how your clients sees it. They use it without knowing how your code looks like inside
  • When testing behaviour, it’s natural to be sociable in tests
    • there’s no reason not to cover your whole implementation (classes/functions) or mock out part of your implementation
  • When testing behaviour, it’s natural to use the highest abstraction level (Facade) of your codebase.
    • Simply use what your clients would use -> your API

Summary

When designing your tests, think in terms of your module behaviour. Try to forget how it achieves it internally.

Test behaviour, not implementation