Stable Software: The Power of Automated Tests
- Date
This article is worth your attention if you
- Are passionate about writing good quality software and want to enhance the stability of your app with tests.
- Are tired of unexpected bugs popping up in your production systems.
- Need help understanding what automated tests are and how to approach them.
Why do we need automated tests?
As engineers, we want to build things that work, but with each new feature we create, we inevitably increase the size and complexity of our apps.
As the product grows, it becomes more and more time-consuming to manually (e.g. with your hands) test every functionality affected by your changes.
The absence of automated tests leads to us either spending too much time and slowing our shipping speed down or spending too little to save the velocity, resulting in new bugs in the backlog along with the late-night calls from PagerDuty.
On the contrary, computers can be programmed to do the same repeatedly. So, let's delegate testing to computers!
Types of tests
The Testing pyramid idea suggests three main types of tests: unit, integration, and end-to-end. Let's dive deep into every kind and understand why we need each.
Unit tests
A unit is a small piece of logic you test in isolation (without relying on other components).
Unit tests are fast. They finish within seconds. Isolation allows them to run them at any point in time, locally and on CI, without spinning up the dependent services / making API and database calls.
Unit test examples: A function that accepts two numbers and sums them together. We want to call it with different arguments and assert that the returned value is correct.
// Function "sum" is the unit const sum = (x, y) => x + y test('sums numbers', () => { // Call the function, record the result const result = sum(1, 2); // Assert the result expect(result).toBe(3) }) test('sums numbers', () => { // Call the function, record the result const result = sum(5, 10); // Assert the result expect(result).toBe(15) })
A more interesting example is the React component that renders some text after the API request is finished. We need to mock the API module to return the necessary values for our tests, render the component and assert the rendered HTML has the content we need.
// "MyComponent" is the unit const MyComponent = () => { const { isLoading } = apiModule.useSomeApiCall(); return isLoading ? <div>Loading...</div> : <div>Hello world</div> } test('renders loading spinner when loading', () => { // Mocking the API module, so that it returns the value we need jest.mock(apiModule).mockReturnValue(() => ({ useSomeApiCall: jest.fn(() => ({ // Return "isLoading: false" for this test case isLoading: false })) })) // Execute the unit (render the component) const result = render(<MyComponent />) // Assert the result result.findByText('Loading...').toBeInTheDocument() }) test('renders text content when not loading', () => { // Mocking the API module jest.mock(apiModule).mockReturnValue(() => ({ useSomeApiCall: jest.fn(() => ({ // Return "isLoading: false" for this test case isLoading: false })) })) // Execute the unit (render the component) const result = render(<MyComponent />) // Assert the result result.findByText('Hello world').toBeInTheDocument() })
Integration tests
When your unit interacts with other units (dependencies), we call it an integration. These tests are slower than unit tests, but they test how the parts of your app connect.
Integration test example: A service that creates users in a database. This requires a DB instance (dependency) to be available when the tests are executed. We will test that the service can create and retrieve a user from the DB.
import db from 'db' // We will be testing "createUser" and "getUser" const createUser = name => db.createUser(name) // creates a user const getUser = name => db.getUserOrNull(name) // retrieves a user or null test("creates and retrieves users", () => { // Try to get a user that doesn't exist, assert Null is returned const nonExistingUser = getUser("i don't exist") expect(nonExistingUser).toBe(null); // Create a user const userName = "test-user" createUser(userName); // Get the user that was just created, assert it's not Null const user = getUser(userName); expect(user).to.not.be(null) })
End-to-end tests
It's an end-to-end test when we test the fully deployed app, where all its dependencies are available. Those tests best simulate actual user behaviour and allow you to catch all possible issues in your app, but they are the slowest type of tests.
Whenever you want to run end-to-end tests, you must provision all the infrastructure and make sure 3rd party providers are available in your environment.
You only want to have them for the mission-critical features of your app.
Let's take a look at an end-to-end test example: Login flow. We want to go to the app, fill in the login details, submit it, and see the welcome message.
test('user can log in', () => { // Visit the login page page.goto('https://example.com/login'); // Fill in the login form page.fill('#username', 'john'); page.fill('#password', 'some-password'); // Click the login button page.click('#login-button'); // Assert the welcome message is visible page.assertTextVisible('Welcome, John!') })
How do you choose what kind of test to write?
Remember that end-to-end tests are slower than integration, and integration tests are slower than unit tests.
If the feature you are working on is mission-critical, consider writing at least one end-to-end test (such as checking how the Login functionality works when developing the Authentication flow).
Besides mission-critical flows, we want to test as many edge cases and various states of the feature as possible. Integration tests allow us to test how the parts of the app work together. Having integration tests for endpoints and client components is a good idea. Endpoints should perform the operations, produce the expected result, and not throw any unexpected errors. Client components should display the correct content and respond to user interactions with how you expect them to respond.
And finally, when should we choose unit tests? All the small functions that can be tested in isolation, such as sum
that sums the numbers, Button
that renders <button>
tag, are great candidates for unit tests. Units are perfect if you follow the Test Driven Development approach.
What's next?
Write some tests! (but start small)
- Install a testing framework that suits your project/language. Each language has a popular library for testing, such as Jest/Vitest for JavaScript, Cypress/Playwright for end-to-end (uses JavaScript as well), JUnit for Java, etc.
- Find a small function in your project and write a unit test for it.
- Write an integration test for some component/service-database interaction
- Choose a critical scenario that can be quickly tested, such as a simple login flow, and write an end-to-end test for that
Do the things above once to understand how it works. Then, do it again during some feature/bug work. Then share it with your colleagues so that you all write tests, save time and sleep better at night!