A Short Recipe for Test-driven Architecture

A Short Recipe for Test-driven Architecture

This blog post about test-driven architecture is written by Marko Bjelac, the Lead Software Developer of Software Sauna. Software Sauna is a Finnish owned full-stack development company that brings you quality code from Croatia agilely and remotely. Read more about Sauna here.

 

Introduction

Here I describe an outside-in test-driven architecture, or test-driven development (TDD) like some like to call it, approach.“Recipe” is intentionally in the title as this is just one of many possible approaches to achieve an effective TDD workflow.

Introduction 2: What is a unit test?

You should write lots of unit tests, adhering to the Test Pyramid metaphor. However, a unit test doesn’t have to be a small test testing a small piece of code. It just has to be a test testing a small piece of behaviour. What is small and big behaviour depends on what level of abstraction are you on. This video has a good explanation.

In this recipe, you will see that I prefer two types of tests: acceptance tests (specifications) and micro-tests. Specifications test the whole system (one behaviour at a time) and micro-tests test small pieces of the system.

Since both are unit tests, having plenty of both is fine (although I tend to have more micro-tests than specifications).

 

The recipe for test-driven architecture

Important note: The steps below should be done per feature, not per system. Don’t do step 1 for all features, then step 2 for all features… First, do all steps for feature 1, then do all steps for feature 2, etc.

Step 1: Specify what the customer wants

Ingredients:

  • You, the developer(s)
  • Your customer (or a knowledgeable representative)
  • A Tester would also be nice as they have a way of uncovering interesting ways of using software

“Specify” means:

  • Talk to the customer
  • Write a specification

Those two should be as close together in time as possible, ideally one after another, or even at the same time – with the customer actively involved in the writing of the specification.

Specification: A “developer” test – a piece of code executable within the test framework of your choice.

Try to make the test code readable to the customer with whom you’re communicating about the requirement. The test should read like a list of examples describing the customer using the software. You can use tools for that (Cucumber, Fitnesse), but modern languages have rich syntaxes and with a bit of tricks you can just use their built-in syntax to get the readability.

The specification will have two sets of interactions:

  • Interacting with the system
  • Interacting with the “test doubles” – code set up around the system to isolate it from the real world

Leave the test double setup for the next step, as the customer doesn’t care about that.

Other steps require only the developer(s).

Step 2: Encapsulate (if needed)

If your system just has an interface (an API or (G)UI) for its customers and doesn’t talk to anything else, you’re done!

Some simple games are like this – think about it: You can play Tetris without the need to get data from a service. Everything can be initialized in the source-code of the game itself. Until you want to save your score…

Most software, however, has additional components it talks to and those components are not your job to implement.

Here is a simple example:

test driven architecture

Interfaces. Interfaces everywhere! But they will help us do this:

test driven architecture

 

Everything is the same – we just swapped some stuff for other stuff:

  • Customer is replaced by the Specification
  • Services are replaced by Test doubles

The system we are testing doesn’t know about the swap because it’s decoupled from external stuff with the help of interfaces. You don’t even need to know the exact service which will be used – just its interface! That way you can defer the decision about which type of service to use (e.g. what database).

Notice: If you haven’t noticed already, we are already in the outer test-driven architecture cycle. 

Run the specification. It should fail for expected reasons. If it does not, find out why not. The specification interacts with the system through an API. Go through each of the API endpoints used in the specification and implement them. How? Read on.

Step 3: Interactors

Every API endpoint is like a little recipe of its own. Get flour, crack eggs, mix.

Write down the recipe steps in the function which will be the implementation of the API endpoint. Each step should be another function. You can use local variables to pipe outputs from one step to other steps.

Take care of function names – they should be readable! Use words & terms from your customer’s domain. If the business deals with bags of cookies, there’s no need to introduce a BakedSweetsBucket, use a CookieBag instead.

Stub the recipe step functions like so:

function openCookieBag(bag: BagOfCookies): boolean {

    throw new Error(“TODO”);

}

 

This pseudocode (resembling Typescript) describes a function that just throws an exception (or error). This is useful when you have to stop the cycle to do something else (like sleep). When you get back to the code, just run the specification – the error traces will tell you exactly what is left to do.

Break down the recipe into functions to get them as small as possible. If some functions are more complex, implement them as sub-interactors, etc.

Step 4: Logic

Having function stubs means we know the APIs of the functions. A function’s API is just its signature: what it takes as arguments, and what it should return. Typeless languages make this even more trivial, but more has to be checked with tests.

Test-drive each function:

  • Write a small test for one behavior of the function
  • Run it: it should fail for an expected reason (only one). If it doesn’t, find out why.
  • Implement just enough code in the function so the test passes.
  • Write a small test for another behaviour of the function, etc.

Step 5: Adapters

Some recipe functions do not compute or decide anything but just serve as senders or receivers of data from the outside world. These are called adapters. They shouldn’t have any logic in them, just mapping outside-data to/from inside-data.

Some examples of adapters are:

  • DAO classes (Data Access Object)
  • HTTP clients

 

Adapters can also be test-driven, although this is harder to do. Manual testing against a real (production) service (or database) is also possible (just be sure to use test data or harmless data). This is fine since the adapter isn’t changing frequently. Be sure to re-test when the adapter or the service API changes. If you have automated tests for adapters, be sure to separate running them from other test runs, because they are typically much slower.

Finishing off

Repeat these steps for every feature, following the test-driven architecture cycle:

  • Implement logic & adapters until the specification for that endpoint passes
  • Write a specification for next endpoint
  • Repeat

The resulting architecture should resemble this:

test driven architecture

In the diagram, each Specification maps to one API endpoint, although that doesn’t have to be the case at all since it is likely that one feature will need multiple endpoints to deliver value to the customer.

Test-driven architecture test scopes

A final word about test scopes, which will hopefully explain how I got to this development pattern. As we see, there are only two types of tests using this pattern (excluding adapter tests for now):

  • Specifications
  • Micro-tests

Specifications test the whole system, while micro-tests test only little bits of logic. None of the interactors have tests! Their correctness is indirectly tested by Specifications. This is fine because interactors should have only one execution path through them. The absence of tests makes them easy to change, move around, split or merge.

Conversely, all logic bits have tests. However, the bits are really small so it’s easy to write exhaustive tests for them, covering all possible cases which are maybe not covered by Specifications. Imagine a software system as a box which contains smaller boxes, which contain smaller boxes, … Each box can be covered by one or more tests:

test driven architecture

Here the same thing is shown in a tree structure to better separate interactors from logic:

test driven architecture

 

Writing tests for just any box is tricky because we don’t know when that box will have to be split into more boxes, or merged with others to form a bigger box. When this happens, the box’s tests have to be changed as well. However, if we have an overarching specification which covers the business functionality of the system, we can split the boxes more and more to get the smallest nuggets of code possible. These will be much less likely to change (maybe we’ll just delete some or add new ones) *and* we can thoroughly test them.

This is how I came around this pattern of development – I can have my cake and eat it too, meaning:

  • the bulk of the code has no tests so it can be freely rearranged
  • multiple-execution path bits (logic) are thoroughly tested, so all conditions are covered
  • entire business functionality is also tested, making sure the wiring of all components is correct

 

ARE YOU SHAPING THE TECHNOLOGY OF THE FUTURE?

Talented offers tailored career opportunities for the makers of the digital services of the future, both employees and independent technology professionals.  Whether you already know what you’re looking for or just want to explore what kind of opportunities would be out there, we’ve got your back.
MORE ABOUT TALENTED