The CUT approach allows to test logically related parts or to gradually replace integration tests with pure unit tests.
Let’s start with the usual app: There is a backend server with data and a frontend application. Logically speaking, those are connected but the backend is using a Java and the frontend uses TypeScript. At first glance, the only way to test this is to
- Set up a database with test data.
- Start a backend server.
- Configure the backend to talk to the database.
- Start the frontend.
- Configure the frontend to talk to the test backend.
- Write some code which executes an operation in the frontend to test the whole.
There are several problems with this:
- If the operation changes the database, you sometimes have to undo this before you can run the next test. The usual example is a test which checks the rendering of a table of users and another test which creates a new user.
- The test executes millions of lines of code. That means a lot of causes for failures which are totally unrelated to the test. The tests are flaky.
- If something goes wrong, you need to analyze what happened. Unlike with unit tests, the problem can be in many places. This takes much more time than just checking the ~ 20 lines executed by a standard unit test.
- It’s quite a lot of effort to make sure you can render the table of users.
- It’s very slow.
- Some unrelated changes can break these tests since they need the whole application.
- Plus several more but we have enough for the moment.
CUT is an approach that can help here.
Step 1: Rendering in the Frontend
Locate the code which renders the table. Ideally, it should look like this:
- Fetch list of elements from backend using REST
- Render each element
Change this code in such a way that the fetching is done independent of the rendering. So if you have:
renderUsers() {
const items = fetchUsers();
return items.map((it) => renderUser(it));
}
replace that with this:
renderUsers() {
const items = fetchUsers();
return renderUserItems(items);
}
renderUserItems(items) {
return items.map((it) => renderUser(it));
}
At first glance, this doesn’t look like an improvement. We have one more method. The key here is that you can now call the render method without fetching data via REST. Next:
- Start the test system.
- Use your browser to connect to the test system.
- Open the network tab.
- Open the users table in your browser.
- Copy the response of
fetchUsers()
into a JSON file. - Write a test that loads the JSON and which calls
renderUserItems()
.
This now gives you a unit test which works even without a running backend.
We have managed to cut the dependency between frontend and backend for this test. But soon, the test will give us a false result: The test database will change and the frontend test will run with outdated input.
Step 2: Keeping the test data up-to-date
We could use the steps above to update the test data every time the test database changes. But a) that would be boring, b) we might forget it, c) we might overlook that a change affects the test data, d) it’s tedious, repetitive manual work. Let’s automate this.
- Find the code which produces the JSON that
fetchUsers()
asks for. - Write a unit test that connects to the test database, calls the code and compares the result with the JSON file in the frontend project.
This means we now have a test which fails when the JSON changes. So in theory, we can notice when we have to update the JSON file. There are some things that are not perfect, though:
- If the test fails, you have to replace the content of the JSON file manually.
- It needs a running test database.
- The test needs to be able to find the JSON file which means it must know the path to the frontend project.
Step 2 a: Update the JSON file
There are several solutions to this:
- Use an assertion that your IDE recognizes and which shows a diff when the test fails. That way, you can open the diff, check the changes, copy the new output, open the JSON file, paste the new content. A bit tedious but if you use keyboard shortcuts, it’s just a few key presses and it’s always the same procedure.
- Add a flag (command line argument, System property, environment variable) which tells the test to overwrite the JSON when the test fails (or always, if you don’t care about wear&tear of your hardware). Since all your source code is under version control, you can check see the diff there and commit or revert.
- Optional: If the file doesn’t exist, create it. This is a bit dangerous but very valuable when you have a REST endpoint with many parameters and you need lots of JSON files. That way, the first version gets created for you and you can always use the diff/copy/paste pattern.
You probably have concerns that mistakes could slip through when people mindlessly update the JSON without checking all the changes, especially when there are a lot.
In my experience, this doesn’t matter. For one, it will rarely happen.
If you have code reviews, then it should be caught there.
Next, you have the old version under version control, so you can always go back and fix the issue. Fixing it will be easy because you now have a unit test that shows you exactly what happens when you change the code.
Remember: Perfection is a vision, not a goal.
Step 2 b: Cut away the test database
Approaches to achieve this from cheapest to most expensive:
- Fill the test database from CSV files. Try to load the CSV in your test instead of connecting to a real database.
- Use an in-memory database for the test. Use the same scripts to set up the in-memory database as the real test database. Try to load only the data that you need.
- If the two databases have slightly different syntax, load the production script and then patch the differences in the test to make the same script work for both.
- Have a unit test that can create the whole test database. The test should verify the contents and dump the database in a form which can be loaded by the in-memory database.
- Use a Docker image for the test database. The test can then run the image and destroy the container afterwards.
Step 2 c: Project organization
To make sure the backend tests can find the frontend files, you have many options:
- Use a monorepo.
- Make sure everyone checks out the two projects in the same folder and using the same names. Then, you can just go one up from the project root to find the other project.
- Use an environment variable, System property or config file to specify the path. In the last case, make sure the name of the config file contains the username (Java: System property user.name) so every developer can have their own copy.
What else can you do?
There are several more things that you can add as needed:
- Change
fetchUsers()
so you can get the URL it will try to fetch from. Put the URL into a JSON file. Load the JSON in the backend and make sure there is a REST endpoint which can handle this URL. That way, you can test the request and make sure the fetching code in the frontend keeps working. - If you do this for every REST endpoint, you can compare the list from the tests against the list of actual endpoints. That way, you can delete unused endpoints or find out which ones don’t have a test, yet.
- You can create several URLs with different parameters to make sure the fetching code works in every case.
Conclusion
The CUT approach allows you to replace complex, slow and flaky integration tests with fast and stable unit tests. At first, it will feel weird to modify files of another project from a unit test or even trying to connect the two projects.
But there are several advantages which aren’t obvious:
- You now have test data for the default case. You can create more test cases by copying parts of the JSON, for example. This means you no longer have to keep all edge cases in your test database.
- This approach works without understanding what the code does and how it works. It’s purely mechanical. So it’s a great way to start writing tests for an unknown project.
- This can be added to existing projects with only small code changes. This is especially important when the code base has few or no tests since every change might break something.
- This is a cheap way to create test data for complex cases, for example by loading the JSON and then duplicating the rows to to trigger paging in the UI rendering. Or you can duplicate the rows and the randomize some fields to get more reasonable test data. Or you can replace some values to test cases like very long user names.
- It gives you a basis for real unit tests in the frontend. Just identify the different cases in the JSON and pick one example for each case. For example, if you have normal and admin users and they look different, then you need two tests. If there is special handling when the name is missing, add one more test for that. Either get the backend to create the fragments of the JSON for you or load the original JSON and then filter it. Make sure you fail the test when expected item is no longer in the list.
- The first test will be somewhat expensive to set up. But after that, it will be cheap to add more tests, for example for validation and error handling, empty results, etc.
Why chained unit test? Because they connect different things in a standard way like the links of a chain.
From a wider perspective, they allow to verify that two things will work together. We use the same approach routinely when we expect the compiler to verify that methods which we call exist and that the parameters are correct. CUT allows to do the same for other things:
- Code and end user documentation.
- Code and formulas in Excel files.
- Validation code which should work exactly the same in frontend and backend.