Skip to content

How to write good tests

Mirko Rischke edited this page Nov 15, 2022 · 13 revisions

Crafting tests for our software is good. But really, achieving good tests nowadays is something just as important.

Give some love to the test code by following some opinionated principles:

Keep the testing code compact and readable

To do that, apply merciless refactoring, just as one should do for production code. Otherwise letting things rot will be like creating the dreaded legacy code on the test side. If tests cannot be easily refactored, then the production code will be hard to refactor as well, leading to legacy production code. Always follow the route of the brave refactorer.

Avoid coding a tautology

Where e.g. the test code generates things using the very same regexp that is used in the parser.

Generally speaking one does not want to duplicate the logic between tests and code. So replicating a regexp or something else in the test is not an option. In this case, thinking about testing input stimulus / output result helps (f(input) -> output), for example if the code is supposed to process a template, don't add values. Instead, test against a computed result.

// use
Assertions.assertThat(processTemplate("param1", "param2")).isEqualTo("this is 'param1', and this is 'param2'"));

// instead of
Assertions.assertThat(processTemplate("param1", "param2")).isEqualTo(String.format("this is '%s', and this is '%s'", param1, param2));

Cover as much of the range as possible to show positive cases and especially erroneous code paths.

Usually this is best achieved when practicing Test Driven Development. With TDD, one can identify at design time what may break. Don't be too shy about coding a simple test case for a minor thing. You never know when, why or how this code will be used or even modified.

One area one could investigate to check the validity of the tests is mutation testing with tools such as PIT.

Don't mock a type you don't own!

This is not a hard line, but crossing this line may have repercussions! (it most likely will)

TDD is just as much about design as it is about test, when mocking an external API the test cannot be used to drive the design, the API belongs to someone else ; this third party can and will change the signature and behaviour of the API.

  1. Imagine code that mocks a third party lib. After a particular upgrade of a third library, the logic might change a bit, but the test suite will execute just fine, because it's mocked. So later on, thinking everything is good to go, the build-wall is green after all, the software is deployed and... Boom
  2. It may be a sign that the current design is not decoupled enough from this third party library.
  3. Also another issue is that the third party lib might be complex and require a lot of mocks to even work properly. That leads to overly specified tests and complex fixtures, which in itself compromises the compact and readable goal. Or to tests which do not cover the code enough, because of the complexity to mock the external system.

Instead, the most common way is to create wrappers around the external lib/system, though one should be aware of the risk of abstraction leakage, where too much low level API, concepts or exceptions, goes beyond the boundary of the wrapper. In order to verify integration with the third party library, write integration tests, and make them as compact and readable as possible as well.

Other people have already written on the matter and experienced pain when mocking a type they didn't own:

Don't mock everything, it's an anti-pattern

If everything is mocked, are we really testing the production code? Don't hesitate to not mock!

Don't mock value objects

Why one would even want to do that?

Because instantiating the object is too painful !? => not a valid reason.

If it's too difficult to create new fixtures, it is a sign the code may need some serious refactoring. An alternative is to create builders for your value objects -- there are tools for that, including IDE plugins, Lombok, and others. One can also create meaningful factory methods in the test classpath.

final class CustomerCreations {
   private CustomerCreations() {}
   public static Customer customer_with_a_single_item_in_the_basket() {
	   // long init sequence
   }
}

Mockito is about focusing on interactions between objects, which is the most essential part of Object Oriented Programming (or messaging).

Read "Growing Object Oriented Software Guided by Tests"

This book is a must read. It goes from nothing to a full-featured application. The authors explain many aspects of the development and how to achieve testing at various stages of the project lifecycle.

If unsure about something, there's always a mailing list with a bunch of brilliant folks.

Clone this wiki locally