Engineering

5 Tips for Writing Better Unit Tests

Shanika W.
February 03, 2021

What are Unit Tests?

Unit testing is the testing of individual parts or components of a program. The purpose of unit testing is to test the functionality of a single component and verify its behavior independently from other components. A unit in unit testing includes the smallest piece of functionality that can be tested. The main objective of a unit test is to isolate the smallest parts of a program and test the functionality of each part. The basic structure of a unit test is designed to check if the output of a defined code block matches the given condition. For example, when testing a function, we specify a set of input arguments and test if the function returns the expected output, as shown below.

func.py

# ==== Function ==== #
class Calculations:
 
  # Function to Test
    def calculate_total(value1, value2):
        return int(value1 + value2)

test.py

# ==== Unit Test ==== #
import unittest
from func import Calculations
 
# Test Class
class TestingFunctions(unittest.TestCase):
 
    # Test Case
    def test_calculation(self):
        input_one = 4
        input_two = 6
        total = Calculations.calculate_total(input_one, input_two)
        # Identify if output is equal to zero
        self.assertEqual(total, 10)
 
if __name__ == '__main__': 
    unittest.main()

Avoid Complex Setups

Since we are testing the functionality of the simplest testable unit during the unit testing, it is better to keep the tests also as simple as possible. One of the major difficulties developers face during unit testing is the test cases becoming hard to understand and change due to making them unnecessarily complex.

The AAA Rule provides a good guideline to keep a unit test simple as much as possible. In unit testing, AAA stands for Arrange, Act, and Assert. The first step is to arrange the test case with the input variables, properties, and conditions to get the expected output. The next step is to act upon your arrangements by calling the methods or functions. In the final step, which is the assertion, you can assert to verify the expected output using a test framework. This method makes the codebase of test cases much easier to read and understand.

import unittest
 
# AAA Methodology
class TestingStringFunctions(unittest.TestCase):
 
    def test_string_manipulation(self):
        # Arrange
        text = "Hello World"
 
        # Act
        new_text = ''.join(reversed(text))
 
        # Assert
        self.assertEqual(new_text, 'dlroW olleH')


Another important thing to consider when performing unit tests is the test dependencies. If a test case depends on another test case, changing a single test case could affect other test cases, which will, in turn, defeat the purpose of individual unit tests.

Exhaustiveness is not the Goal

It is always a good idea to perform comprehensive unit testing for your program. However, it does not mean that you should write test cases for each and every scenario. Considering every edge case beforehand is a waste of valuable development time.

Tests should be straight forward covering the common flow of your code block. Once simple tests are passing, you can increase the coverage of the test to include edge cases and cover multiple boundaries. When a bug is discovered in the development cycle, you can expand the test coverage to include that bug. This will keep your test cases up to date without having to create separate test cases to tackle the bugs.

Write Tests First When Fixing Bugs

The first intention of a developer when discovering a bug is to try and fix it somehow. Although she would be able to fix the bug at that time, her understanding of the bug may be limited and it might cause new issues down the pipeline in the future.

Therefore, it is always a good practice to create a test for the discovered bug encompassing the components that cause the bug. This way, you can verify if the bug relates to the indicated components or is caused by an external factor. Having a simple and straightforward test will lead to an easier bug fix by quickly exposing the problems.

When you write your test, make sure to run it before fixing the bug and verifying that the test fails. Otherwise, you may write a test that doesn’t actually encapsulate the bug, therefore defeating the entire purpose.

Once a bug is fixed, you should not discard that unit test. These tests can then also be used in regression testing to test the overall functionality of the program. It will be an extra benefit as we know both the failing and passing conditions of that test in a future event when a similar bug is discovered. That will also make it easy to know where to apply the fix.

Mock with Caution

Mocking is a way of simulating the behavior of a real object in a controlled manner by creating an artificial object (mock object) similar to it. Mock objects enable developers to mock the behavior of complex functions without having to invoke the function. This is useful when dealing with external services where you require those services only to execute the underlying code block but they are not needed for the test case.

Mocks are also useful in increasing the speed and efficiency of a test case. Without mocks, if a test calls for an external library or a function, you must wait for that function to be completed to continue the test. This will lead to slow test execution times and can even introduce unnecessary errors or behavior inside the test case. For example, when dealing with the file systems and network functions, you could use a mock object to simulate those behaviors, effectively speeding up the test while focusing on the core functionality of the test.

import unittest
from unittest import mock
from func import delete_file
 
class TestFunctions(unittest.TestCase):
    
    # Mock the os module
    @mock.patch('func.os')
    def test_file_delete(self, mockobj):
        file_name = "test.txt"
        delete_file(str(file_name))
        # Verify the parameters were passed correctly to the function
        mockobj.remove.assert_called_with(str(file_name))

The most important thing to remember when using mocks is not to mock the functionality of the test case. Mocks should not be used if you are testing for database queries, API calls, or any functionality that has a direct impact on your overall test.

Keep Tests Independent

The tests should be orthogonal to each other and be used to test a single functionality at a time. The test classes should be tested independently and should not depend on anything other than the testing framework.

When an order is needed to carry out unit testing, you can use the inbuilt setup and teardown methods of the test framework to isolate the test cases. A good test case should not be affected by any implementation changes. When dealing with API changes, a program with a solid code base with correctly implemented individual unit tests is vital for identifying errors and making the program up and running again.

Besides, creating independent tests enables us to easily modify each test case while increasing the test spectrum without causing any side effects to other tests. In test automation environments, independent tests are the preferred way to integrate with CI/CD pipelines.

The next step

Unit tests are a great way to discover and resolve bugs in units, ensuring that every single part of the program works well. However, if you want to test different parts of the program working together, in their actual environment, you need to go for integration testing or end-to-end. You can learn more about the testing hierarchy here.

Integration tests abstracted up a level from unit tests, executing and asserting on code paths that traverse between multiple units or modules. Now we're starting to ensure the different pieces of our application interact with each other as expected.

Even farther are end-to-end (E2E) tests. These are tests aimed to cover the entire surface area of your application from top to bottom. These should generally follow the code paths expected from your end-users to ensure they're as close to reality as possible.

Looking for an easy way to take the next step beyond unit testing? Check out walrus.ai

  • Tests can be written in plain english — no difficult setup required
  • Test your most challenging user experiences – emails, multi-user flows, third-party integrations
  • Zero maintenance — walrus.ai handles test maintenance for you, so you never experience flakes

Follow us on Twitter