Unit testing has always been an integral part of software engineering because it ensures that problems in the code are isolated and fixed long before they become an issue in production. With the introduction and adoption of Agile framework, unit testing has changed dramatically because of the test-driven development (TDD) methodology. TDD is a software development strategy in which unit tests are used to drive the development process. In this blog, I explain how to practice TDD and share some tips on how to write great unit tests using MUnit.
Benefits of Test-Driven Development
There are many benefits of the TDD approach. Among the obvious ones are fewer defects and increased code quality, which is achieved when the code is shorter and less complex. And this is precisely how the code is written when practising TDD. A well-structured 'simple' code is also easier to read and understand, which significantly enhances its maintainability.
Other benefits that may not be so easy to identify is the creation of a regression test suite. It helps mitigate the risk of introducing bugs from minor changes in the code. Unit tests can be a part of the build lifecycle to ensure developers don't break other features. They see the problem even before deploy the new code. Not surprisingly, TDD is usually preferred when CI/CD (i.e. continuous integration and continuous development) is in the picture.
However, not everything is perfect with TDD. Practising TDD requires additional development time and may increase maintenance cost if not used correctly. All bugs identified in production should be fixed in two places - the actual code and the unit test.
How to apply TDD to MuleSoft projects.
Like always in MuleSoft, start with productising your initiatives and listing their desired features. Then identify the APIs in the application network and provide the functionality of the feature. To further break down the APIs, my suggestion is to use the VETRO Pattern (Validate, Enrich, Transform, Route and Operate), which separates the main APIs' functions. So, the next step is to create a Mule sub-flow for each of these functions, if applicable.
Now, with the basic setup in place, you can start a bottom-up test-driven development phase with multiple iterations of the same simple steps:
- Write a test for one of the sub-flows and assert the desired outcome.
- Code the simplest solution to make the test pass.
- Run the test and verify it passes.
- Refactor the code to acceptable standards.
- Repeat with the next sub-flow.
When all the sub-flows are coded and tested successfully, write a test for the main flow and follow the same steps. The idea behind this approach is that at the end, the main flow not only includes the integrated functionality of its sub-flows but also provides the desired functionality of the API. The good thing is that it's been unit-tested already!
Tips on writing efficient MUnit Tests
Bellow you will find a few tips on writing successful MUnit tests and some examples.
Test one thing at a time
It is impossible to write distinct tests for each Mule processor! While a unit-test covering a (sub) flow can isolate and test a substantial part of the code with reasonable effort.
For example, let us assume that we must implement 3 functions:
- Get values from a configuration file,
- Transform the message,
- Call an external endpoint.
So, we start by creating a test for the first independent function that constitutes a unit of work. It can be coded and tested in isolation from the rest of the functions.
And then, we proceed with coding the solution that makes this test pass.
Expect the same results.
No matter how many times you run the MUnit test, it should provide the same result if the code under test and the test itself have not changed.
Don't assert any fixed values because their values vary in different environments. Instead, verifying that the variables are not null is enough to assert that our code works.
Mock out all internal and external calls
All external calls should be mocked because they are not usually within the scope of unit testing. We also suggest mocking all internal calls to avoid overlaps during multiple tests.
Continue with the same test case and, after 3 of your sub-flows have been created and tested, proceed with coding the main flow. Since all the sub-flows have passed the test, we can mock them out and concentrate on testing any remaining message processors. In our example, we assert the final response of the main flow.
And here is the test after mocking the sub-flows:
Test the error scenarios too
Many developers test only the happy paths and omit the 'rainy day scenarios' even though they are equally important. Ideally, there should be enough tests to assert all possible outcomes.
Here we are testing our code's behaviour in case the external endpoint returns a 401 HTTP Code (Unauthorized).
Do not make tests dependent on each other.
That makes the tests hard to debug and maintain. There should not be any assumption or requirement that your unit tests should run in any specific order.
Use a naming convention.
Give a meaningful and friendly name to each MUnit test, relate those names to the functionality it tests.
To sum up, TDD with the use of MUnit is an excellent way to identify defects at an early stage during development. If done efficiently, it can improve code quality, reduce complexity, regression defects and enable safe refactoring of the code.
With a robust and mature practice of TDD, the development team can deliver high-quality products to the business and its stakeholders.