Clean Up Your Frontend Tests: Part 1

Why we focus on integration tests for UI components

Stawiarski Jakub
TechTalks@Vattenfall

--

This article is a part of a cycle. All parts are:

  1. Why we focus on integration tests for UI components.
  2. Tips & Tricks.
  3. Reusable testing modules.
  4. Using Page Objects to manage complexity.

Credits to Paweł Nowakowski, co-creator of the series.

We believe that writing automated tests for a frontend app is crucial.
We have written quite a lot of them. A report for one of the projects, which was made up of multiple modules, states we recently surpassed 3K tests.

The effort of writing all of the test code seems to have paid back with a reduced bug count.

What we learned along the way, is that writing a lot of bad tests might actually hurt you more than not writing them at all. You will need to make sure to consider things like:

  • How should I write the test to maximize the feeling of confidence they give me? In other words, how to make sure that passing tests really mean that code works?
  • How should I write tests so that I do not make introducing changes too difficult?
  • How can I keep a good balance between the test execution times and the confidence they give me?
  • How should I keep my tests readable, so that they can act as living documentation of my code?
  • How can I make sure my tests do not randomly fail so that I really look into each test failure?

What we will cover

We would like to compile a list of practices and solutions we came up with to solve the issues mentioned above. In this article, we will focus on discussing what granularity of tests we mostly use.

As the project we take examples from is mostly written in Angular we will be giving code fragments that use that framework. Most of the solutions, guidelines, and principles could be ported to other technologies as well.
We also took quite a bit of inspiration from the great Testing library.
The examples will be based on standard Angular testing utilities, though.

Why do we focus on integration tests?

There seems to be a lot of community pressure to focus on unit tests. Both integration and end-to-end test are often shown as a necessary evil that is unstable and slow.

We find it mostly not to be true when it comes to testing UI components. From our experience, the integration tests run fast enough and are very stable when written correctly. For example, running all 851 tests for one of our modules locally takes around 17 seconds.

You can come across similar opinions in the React world, too.

Some of the misunderstandings or conflicting opinions may stem from the fact that there are multiple definitions of what an integration test is. We define such tests as ones that:

  • Verify that the code integrates with the framework code correctly.
    This means that we want to verify that the template renders and behaves as it supposed to, any exposed inputs and outputs work, the component uses the lifecycle methods in the right manner, etc.
  • Verify the implementation of a feature in a way similar to how it will be used by the user. This means that, for example, if the user should click a button to open a dialog, the tests should simulate the click event on a DOM element instead of calling a method of a component.
  • Do not contain any details on the internal implementation of the feature.
    If the feature needs any child components or services, the actual implementations of those should be used in most cases.
  • Do not require any external services and/or backend APIs to be running.
    Solutions like HttpClientTestingModule or Pact should be used to mock out the external calls.

Read on for an example showing how focusing on such tests can reduce the number of tests that need to be written and improve the confidence in the correctness of the code.

Issues with unit tests for Angular components

Let’s say we have a component that is responsible for displaying a list of todos.

As you can see it uses a child component which handles filtering the todo items by name. For now, let’s keep things simple. In actual code, this component might provide some debouncing, minimum length of a search term, etc.

Angular documentation shows how to write separate tests for such components. For example, one could write:

Tests written like this would pass, even though there is an obvious bug in here. The test for the todo list assumes that the search term will always be published, even if it is empty. This is not true, because it is only published when a user changes the input value.

This is a quite simple example. The bug can be easily spotted at first glance.
In real code, the test files may be much more complex. Spotting such a case would be much harder.

This kind of issue is a false positive. Tests assume that the code works correctly, and wrongly report that it does. Having many cases like this may lead to losing trust in the test code. Shortly after you wake up in a world where developers waste time writing tests, but every change requires a retest of the whole application.

An additional drawback of tests written this way is that they are strongly dependent on the implementation details. Your users do not really care how you break down your application into components, nor if the components talk to each other using event emitters, services, state store, etc. If at some point you decide to change the implementation you will need to rewrite the tests almost completely. This further reduces the trust you can put in them and makes introducing changes unnecessarily difficult.

Integration tests

Now let’s take a look at how the tests could be written to match the definition of integration tests we provided in the beginning.

This test fails as it should.

You can notice that we can now change how communication between the components works. The test checks that there is a filter field and that it works, not how it works. We also end up with a little less test code, as we don’t really need to write separate test files for each component.

Say that at some point you decide that communicating using the @Output was not the best idea. One example would be that you need to split the filter component further and you start to see some prop drilling. If you introduce a provider instead, the worst-case scenario is that you will need to add it to the testing module passed to the TestBed . If the component provides the service or state itself, you should not need to change anything. All of the existing assertions should still work, as the feature did not change from the user's point of view. This encourages refactoring and brings a lot of confidence.

You may notice that the test code is quite verbose. It contains some repetition. The it sections are not as clear as they could be. In short, the test file can still be improved a lot. We will touch on ways to do that in future articles.

Should we avoid writing unit tests completely?

No, unit tests still have their uses. We just find testing UI components that way troublesome.

Sometimes your component might grow a lot. It can contain complicated logic. In such cases, we usually try to extract it to separate classes or functions. It improves the readability as we do not mix logic with framework-dependent code. We find writing unit tests for such classes and functions beneficial. If such tests exist we can limit the cases covered by integration tests for components using those classes to the minimum.

Nothing is for free

Shifting the focus to this type of test is not without trade-offs. There will be cases where it will require more repetitions. The test files may also grow too large and complex if you are not careful enough. We will cover ways in how we deal with these issues in future articles.

Summary

We hope we shown some of the benefits of writing integration tests. If you wish you can continue reading with the second part of this series.

--

--