I was pair programming with a friend of mine (who is getting more in depth into programming) on a small project and the subject of tests (as in Unit Tests) came up to try and debug some behaviour. My friend then communicated that they have not written many tests and was a bit unsure of how to get started. To which I very much appreciated their honesty (it’s often not easy).
It made me reflect on my experience (11 years+ in Software Development as of writing) and how I started to use Unit Tests. I feel like working in Technology there is this expectation that everyone knows how to ensure everything works 100% as expected and not knowing how to test things or even asking about how to test things is a taboo subject (which is not helpful to anyone).
Unit Test vs Integration Test
Before go any further we need to address the elephant in the room that is the difference between Unit Tests and Integration Tests (as the difference is really important and often the two are used interchangeably.
Unit Tests
Unit tests only test one piece of code at a time typically “mocking” other parts of the application to verify the piece of code under test works as expected.
One example of a Unit Test would be to test a database “repository” layer. In this case you would have a local database that your tests would connect to asserting that it could read/write data as expected. It isn’t important of where this code is used just that we can read/write to the database.
A second example might be a business logic function (for example converting Celsius to Fahrenheit). This function has no downstream dependencies but we should input some known values and assert they match what the expected resulting values should be.
A third example might be a function that calls an external API. In this case there are libraries that can mock a HTTP request (Node.JS “nock”, Python “requests-mock” are such examples). This allows you in isolation to test converting JSON response to whatever your app returns (and checking any exceptions that should be raised).
This highlights a downside/consideration that must be acknowledged. Your tests are only as good/accurate as the mocks in your tests. Also any changes to downstream services must be reflected in your mocks otherwise your tests will pass but your app will not work as expected.
Integration Tests
Integration Tests on the other hand are designed to test the whole application. Taking an API as an example this means would mean executing a HTTP request and connecting to downstream services, databases as required.
This is equivalent to someone executing the request against your API (in this example) if it were deployed (I.e. like a curl command).
As these do not mock downstream dependencies (usually…) this means that they are more reliable at verifying whether your app will run before deploying. However generally (at least in my experience) they can be harder and slower to setup.
Which Should I Use?
Whilst both seems an obvious answer (or some might say only Integration Tests), in my experience you can get good code and behaviour coverage with Unit Tests whilst actually speeding up development velocity. Unit Tests generally (ignoring ones that need a external dependency such as a database) are easier to get running in CI/CD pipelines as well.
However, I will say that when using Unit Tests you should make use of “spies” in your tests. Spying involves looking at what another piece of code in your app (usually mocked in a Unit Test) and what it was called with. Although not foolproof it does allow you to assert that if the mock is correct in what it returns you are calling it with the right thing (query params, or specific headers, etc).
In my experience having a high degree of code coverage in Unit Tests along with with “golden sample” or core business logic Integration Tests has served me well, allowing fast development, bug finding/remediation as well as a high level of confidence things work as expected.
Console.log()
I remember in University and in my very first projects I would run code and log out messages to try and work out what was happening (who am I kidding I think we all still do this 😆). The idea of writing extra code to test my code was something that was not really taught on how to do well (or at all).
This normally meant code was littered with console log messages whilst developing which would then be cleaned up later. Although this approach works to build a project and get it running it does not give you any regression safety to when changes or new features need to be added to the application.
The other thing this approach requires is the application to run/connect to downstream services OR to deploy your code and look at the response. This can potentially have a very slow “feedback loop”, not to mention frustrating.
Baby Steps
I think it would have been when I was transitioning from a junior to a mid level Software Developer that I started getting exposure to applications that had Unit Tests already included (.NET Framework, JUnit). At this point I didn’t know the value of the tests but it did force me to learn testing frameworks.
It’s probably around this time in any Software Developer’s career when you start understanding tooling and how it makes you work smarter/quicker not harder. For example learning how to do “step through debugging” and work through an application line by line is something that once you get used to you wonder how you ever worked without it.
When the above two things are combined it means that almost any problem that you have (checking bad data), or finding the source of a bug you simply write a test and then “step through debug” your test case that you are interested in. If you write your tests first (within reason) this is what is called “Test Driven Development”. I.e. write a test that fails at first (as your code is not complete or there is a bug you have surfaced) and then when you fix/implement the code the test will pass.
In my general experience using TDD for backend (or say Storybooks for UI) speeds up development by at considerable amount. Not only does it remove the frustrating time consuming task of writing tests (as you write them as you go) it also means that every test you write can be used to safeguard regressions in the future.
However, there is still one issue outstanding. How do you handle external dependencies in tests (such as APIs, Database, or Cloud Services)? See the next section.
The Holy Grail
Sometimes there might be a library to help you with external services (AWS Boto or HTTP mocking libraries such as Node.JS nock) but there will be parts of your app and flows that you want to have consistent behaviour in your application. For example how would you handle testing a “Create” step? You could just create some data that has a randomised component but this means that your tests could be brittle.
A solution to this is to “mock” and also “spy” on parts of your application.
Mock
Mocking simply means to replace a response or some part of the application with a “canned” or consistent response. Some common examples are mocking the “current time” or a database repository layer. By mocking these out it means you can test small parts of your application independently. It’s worth noting that a mock can have one response for all tests or be different per test (it’s test/use case specific).
Lets walk through an example. Say we have an API that we want to test and this saves data to a database through a repository layer. When testing the API we can mock out the repository layer returning a “known good” response in some Unit Tests or “known bad” responses in other Unit Tests. By using these “mocks” the tests are repeatable regardless of what state our local environment is in or if it is ran in a CI/CD pipeline.
Using the above API example once you learn how to mock things you can verify behaviour as required: authentication/authorisation, external APIs or Cloud resources.
Spies
Not the James Bond kind! A spy is just a way of saying “watch this object” so that you can check things such as:
- Was it called
- Was it called n times
- When called did you call it with x, y or z
Note that you can “spy” on almost anything, it doesn’t need to be mocked.
In some testing frameworks “mocks” are also “spies” but sometimes you need to be explicit and “spy” on a “mock”.
This gives you a really powerful pattern of being able to return a known good object (though the same risk of “good mocks” in Unit Tests applies) AND you know that it has been called with the correct parameters.
In a previous web application that connected to private internal microservices this approach allowed me to develop the whole backend API without having to connect to them. It also meant that if there was an issue (and I verified the mock was correct) then the issue had to be downstream as the “spies” verified I passed the correct information downstream.
Pseudo Code Example
Find below a non functional example of how a test could be structured to use both Mocks and Spies.
# code file
function createObject(obj):
const id = db.save(obj);
return id;
# test file
# Arrange
const mocked = mock(db.save).returnValue(3);
const obj = {...};
# Act
const result = someFunction(obj);
# Assert
expect(result).toEqual(3);
expect(mocked).toBeCalledWith(obj);
Lets step through what is happening in the above example. We have a function createObject
that saves data to a database which is then the main function under test in the test file.
Note: Although this example shows a DB potentially in use (that we are mocking), in a production application this test should actually connect to a local DB.
Instead of requiring a database in the test we have mocked this out with the “mock” function and it is always going to return the value 3 for the ID that it would return.
It’s worth noting in this example above the “mocked” object is also a “spy”.
We can then test the function as expected (i.e. returning with the value 3). This means the functions works…right?
If we used just the “mock” this could hide a bug where we forgot to pass the “obj” to the db.save()
method and this would not become apparent in the test as we are mocking all responses with the value 3 regardless of what is passed to it. Our tests would pass but the application would probably “crash and burn” at runtime as the “obj” would be a required parameter of the db.save()
method.
This is where “spies” really compliment “mocks”. The final line in the test checks that the spy (the mocked db.save()
method) has been called with the object we pass into the createObject()
function. This totally removes the potential bug highlighted in the previous paragraph.
Examples
I’m going to work on a sample Open Source repository that has some different languages/frameworks setup to use mocks/spies for educational purposes as I find getting a working example by following the docs can sometimes not be that straight forward.
I also find personally having an example done that I can “reverse engineer” and also play with is more educational.
The unit test example repository can be found here.
Final Thoughts
When starting out with tests it can be a challenge to get things working/running especially when starting a new project that does not have tests or swapping languages/frameworks. I know personally swapping from Node.JS/Jest to Python/Pytest was frustrating at times until you learned the “patterns” of getting stuff to work.
I have also burned a week in a previous project (Next.JS) getting test patterns for a backend API. However the time spent on this is not a “black hole” and needs to be viewed as an investment in the long term not just a delay for a deliverable.
Too often I have seen developers “burn time” in trying to debug an application or have people test applications manually for regression tests. Instead investment in getting tests running would have made their lives much easier.
So I implore you the next time that build an app (or are currently working on one) have a go at including some Unit Tests, or using a UI Testing framework such as Storybooks to try and make your development life easier.
Finally I will say that you don’t need to have 100% coverage on Tests (and is probably not worth it in terms of time). Instead find a happy medium that covers the parts of your app that have risk (business logic, calculations, etc).
Leave a Reply