SaFi Bank Space : Automated testing

This text describes our approach for writing automated tests for microservices. As it was described in main section, we use two separate sections for tests:

  • src/test - this section contains “unit” tests, better name would be “isolated tests”. However word unit is not exact here. In general, all tests here should be written in the way, that command ./gradlew clean test is executable even without access to dev environment. DB (if any) is started usually using test containers. If there is Kafka in place used within module for async (produces & consumers are both in one microservice), then this phase also tests this case using Kafka TestContainers.

  • src/itest - this section contains integration tests. These tests rely on existing infrastructure of “dev” environment including database, distributed caches etc. Similar to src/test tests, these tests should be executable all the time, developers machine has network access to “dev”, and no additional setup is needed. So ./gradlew clean integrationTest should run without usage of test containers, docker compose, mocks or any other tooling.

Which test type when?

In general, there is often doubt, whether to write unit/isolated tests using local resources or mocks, or whether to write integration tests. Developers often prefer to have their silos and be independent by their code from the rest and they act accordingly. However we are building one solution, which should work smoothly together. Therefore:

  • Write isolated tests in test phase only if, there is quite difficult algorithm with many edge cases, so there would be tens, hundreds or even thousands of call to another component needed to validate all the cases. Then use mocks. But be careful! We really do not want to verify, that our code works against the mock. Mock is powerful technical approach, but often misused.

  • Write itest when main functionality is about data flow, not data processing and calculations. Also write it, when it is easy to verify outcome. And finally write it, when it is easier to call real system, instead of mocking it. This is the case for almost all internal infrastructure.

Standard “test” phase tests

As mentioned above, these tests are stored in src/test section of source code of microservice. This convention comes from Maven, and is in general accepted as de-facto standard. Structure is already described here.

Gradle task invoking all the tests in this section is executed by test target. To always run newest code it is recommended to clean previously compiled output, so final command using gradle wrapper would look like: ./gradlew clean test. The rules are:

  • existing JVM.

  • docker instance to support test containers.

  • no other settings nor processes including mock servers, docker compose need to be executed, or running before.

So in case, JVM and docker is installed, the command mentioned above should always run successfully.

As our main development framework is Micronaut, we are using its support for testing, and are using it together with JUnit5.

  • For container configuration we use application-test.yml which contains all the relevant information for container being able to start locally without any other prerequisites. In example we use datasource, which creates and uses clean postgres instance run within docker container.

  • When using JUnit5 behavior, all test classes should use annotation @MicronautTest

  • All tests using only local storage are then freely run against this DB.

    • As the DB is clean, test can be quite messy, however, they should run always within transactions, what is btw default behaviour.

    • Tests should not rely on existing data, but should create new ones. Also, should be durable enough, so existing data in DB would not break them.

    • Regarding transaction behaviour, see this as an example:

      • methods annotaded by @Test are running within transaction, and are rollbacked after test is over.

      • methods annotated by @BeforeEach are run within isolated transaction, which is commited. This means, that in this method we can prepare test data for each test-method within test class.

      • analogically also @AfterEachmethod is run is separate transaction to clean up, what was created in @BeforeEach. This method is also commited.

  • In these tests we can also test controllers using standardised HttpClient.

Integration tests

Currently, integration tests are postponed and all the effort goes primarily into component testing

As we build SaFi as microservices solution we need to be sure that whole system works together. On one side, we can rigidly define interfaces and have processes over its upgrades, on other side, this still doesn’t guarantee that everything works smoothly. Therefore, we need to either do the manual tests or write automated integration tests.

Integration tests on SaFi are always written towards DEV environment. Tests are written in similar approach as regular tests, however instead of using local DB, Kafka or mocks, we actually use DEV DB, DEV Kafka, and we run tests ideally against sandboxes of our integration partners.

Gradle command invoking integration tests is ./gradlew clean integrationTest.

Writing integration tests can be little bit trickier. Integration tests usually modify target environment and these changes are permanent. There are few main approaches, how to tackle this:

  • Always create new set of underlaying data. Eg. If you want to transfer money from the account, be sure to create a new account with sufficient funding before the test. By this approach you can be sure, that nobody will interfere with the data and your test will for sure test, whether microservices understand each other.

  • Use existing agreed underlaying data, which are agreed to be stable. Eg. (this is just an example, not REAL constrain) For each account there is need to have and owner. We can reuse existing owner with Id 123, as we agreed, that user with this id is for automated testing purposes only and should be keep intact. Also all automated tests using this user will use it only as “read only” user. By this approach it can be much faster, create the test. Also, for people using DEV environment directly, we can agree, that all users with last name prefixed for example: “Autotest” are used by tests only, and should not be modified by manual testers.

  • Use fixtures, which combine previous two approaches. Eg. There can be fixture creating new account using existing user. This code typically will reside in common test library, which will help us writing the tests.

In general, integration tests are preferred for most cases. The only issue with them is its performance, therefore, when microservices build (including tests and itests) takes over 10 minutes, then we might consider:

  • optimise tests, when there is not too many of them but they take ages

  • split microservice into smaller ones, as too many complex tests indicate, that there also is too much logic in the one component. It actually isn’t as difficult as it looks on first point of view because we already have tests in place. (smile)