Skip to main content

Testing Principles

This section will cover a number of the core principles and best practices that are important to remember when writing tests. They aim to give you an understanding of the different aspects of testing, and what we should be aiming to achieve in our testing.

The goal of tests

At a high level, the goal of writing tests is to give us more confidence in our code, whether that be new code or changes to existing code. We write our tests to match the expected behaviour of a unit of code, and then as our work progresses those tests can tell us either "yes, your code still does what it's meant to" or "no, your code doesn't do what it's meant to". We want to be sure that our application works when used by our users.

The testing pyramid

The testing pyramid is a concept that helps visualise the different types of tests and how we should be distributing our testing efforts. It was introduced by Mike Cohn in his book "Succeeding with Agile.", and has since grown into a common reference point in testing software across the industry.

Here's a nice image shamelessly stolen from the internet:

testing-pyramid

The pyramid is divided into three layers:

Unit tests

These tests form the base of the pyramid, and are typically the most numerous within a codebase. Unit tests focus on testing individual units of code (hence the name), ensuring that given a set of inputs, the code produces the expected output. They are, for the most part, the easiest to write, fastest to run, and easiest to maintain, which is why they are the most numerous. At the same time though, unit tests give us the least confidence in our code, because while a single unit of code might work on its own rarely does code ever exist on its own within a codebase. Unit tests don't validate that code works when integrated with other code as it would be in a running application.

Integration tests

These tests are the next layer up, and are usually less numerous than unit tests. Integration tests are focused on testing interactions between components, functions, and classes, and ensuring that they work in concert with one another. They are great for catching errors that may be missed in unit tests which are a result of there being an issue with the integration of these various units. While integration tests are typically more difficult to setup, write, and maintain, as well as slower to execute, they provide greater confidence in the overall functioning of our application.

End to end (E2E) tests

These tests form the apex of our pyramid. E2E tests are generally the least numerous as a result of them being harder to setup and write, far slower to execute, and the complexity in executing them in a consistent and reliable manner. They offer us far more confidence in our code than the last two layers however, as they interact with our application as a user would. They cover the full user experience, and in a lot of ways can replace manually testing the application.

The testing pyramid aims to represent the effort:value ratio of the different types of testing. By using it as a foundation for where you should focus your testing efforts, you will ideally be getting as much confidence from your tests as possible while minimising effort spent.

The anatomy of a good test

Our tests should match the use cases of what we're testing. In doing so, they're verifying that our code works as expected. So what would this look like in practice?

Say we had a ticket to implement a new bonus offer redemption screen in the app. The general idea is that a user will receive a bonus code, enter it into a field, be shown the details of the bonus, and then be able to redeem it. When we look at the ticket it has the following acceptance criteria:

  • As a user I should be able to fill out the redeem code field with my bonus code.
  • As a user I should be shown the details of the bonus I want to redeem.
  • As a user I should be able to redeem the bonus, and see a success message upon doing so.

Here we already have a very solid foundation for our tests. This acceptance criteria very clearly lays out what our code should do, and what the user should experience. From them we can start writing some happy path (paths that assume everything works as intended) test cases:

describe('Bonus redemption screen', () => {
it('Allows users to enter a bonus code', () => {})
it('Shows users the details of the bonus being redeemed', () => {})
it('Displays a success message when a bonus is successfully redeemed', () => {})
});

Immediately we have a great start to our tests, and as long as these continue to pass we can be sure that our code is doing what it's supposed to be doing. There's also a couple other things to note:

  • Our test names are clear and descriptive. They describe the behaviour of what is being tested, and what the user should experience. This is important because it makes our tests easier to parse, and also helps document our code.
  • The tests are focused and isolated. They focus on a single piece of functionality rather than multiple concerns. This makes them easier to maintain and debug.

From here we would start looking at edge cases, and expected behaviour when things go wrong.

describe('Bonus redemption screen', () => {
it('Prevents a user from entering invalid codes', () => {})
it('Alerts the user if a bonus has already been redeemed', () => {})
it('Displays an error message if there is a problem in the redemption process', () => {})
// and any other potential edge cases
});

These tests are similar to above, except now we're focusing on edge cases and failure scenarios.

These points are fairly generic, but are important to remember as they will serve as the foundation of our testing mentality. We'll go into more detail on best practices in the next section.