TestingFeb 10, 202512 min read

Guide to unit testing

Jacob Schmitt

Senior Technical Content Marketing Manager

Testing blocks build a grid.

Unit testing is a software testing methodology that tests the behavior of individual functional units of code. Through unit testing, developers can verify that their code performs as intended.

Providing an opportunity to catch bugs, validate the implementation of logic, and assess the quality of the code, unit testing enhances the quality of applications and preemptively identifies problems before they become major issues.

Developers can perform unit testing manually or incorporate unit tests as part of an automated process. A CI/CD pipeline can execute unit tests every time developers commit new code, ensuring that applications are continuously tested as they develop over time.

What is unit testing?

Unit testing focuses on the smallest functional units of code. This often means evaluating the behavior of a single function by testing its output against an expected result.

Unit testing can be applied as a specific testing technique, or as a broader philosophy for building applications. The core principle of the unit testing philosophy is that code should be written as independent functional units. A unit test is created to validate the behavior of each unit.

With unit testing’s low-level approach to evaluating code, it is not meant to provide a holistic evaluation of system behavior. Unit testing can be integrated into a strategy like the testing pyramid to provide comprehensive coverage of an application. On its own, unit testing is all about understanding how individual units of code behave in isolation. It does not reveal the behavior of the system as a whole.

Why perform unit testing?

Unit testing promotes reliable, maintainable, and high-quality code. Thorough testing allows developers to catch bugs before they affect end-users. Some of the main benefits of unit testing include:

  • Defect management: Unit testing enables developers to catch bugs. By identifying and documenting issues early in the development cycle, DevOps teams can develop a defect management strategy to improve the quality of the software.
  • Higher quality code: Both the practice and the theory of unit testing lead to higher code quality. Unit testing encourages developers to write well-structured and well-designed code and verify the behavior of that code programmatically.
  • Documentation: Writing, executing, and logging unit tests generates living documentation for the codebase. Each unit test describes functionality and provides a working example of how the code should behave. Other developers can read through unit tests to understand the code and get up to speed quickly.
  • Validate logic: Unit testing provides developers with a direct means of verifying that their code implements logic properly. Well-designed unit tests ensure that business logic is properly represented in code, ensuring that applications behave as the business intends.

How to write unit tests

Let’s take a look at the simplest example of a unit test.

For this demonstration, you’ll need Python and the pytest framework. You can unit test in any programming language and there are a wide variety of different unit testing frameworks available. While this code is Python, the principles of unit testing explored in this example are applicable no matter which language you write code in.

Open your terminal and start a virtual environment:

python3 -m venv venv
source venv/bin/activate

Install pytest:

pip install pytest

Now create a file named unit_test.py and add the following code:

def add(x, y):  
   return x + y

def test_add():  
   assert add(2, 2) == 5

This defines an addition function that returns the sum of two numbers. It uses the assert statement from pytest to verify that your addition function works properly.

The assert statement is useful when you know what the output of our function should be. For this example, we’ve asked it to evaluate if two plus two equals five. If our addition function works as intended, it should return a value of four and the test will fail.

Let’s run the test and see the results. Type into your terminal:

pytest unit_test.py

The output should look like this:

collected 1 item
unit_test.py F [100%]
=================================== FAILURES ===================================
___________________________________ test_add ___________________________________
def test_add():
> assert add(2, 2) == 5
E assert 4 == 5
E + where 4 = add(2, 2)
unit_test.py:5: AssertionError
=========================== short test summary info ============================
FAILED unit_test.py::test_add - assert 4 == 5
============================== 1 failed in 0.01s ===============================

Our test failed as expected. Let’s correct the failure and add some more code to our unit_test.py file to see what a successful test looks like.

def add(x, y):  
   return x + y

def test_add():  
   assert add(2, 2) == 4

def subtract(x, y):  
   return x - y

def test_subtract():  
   assert subtract(3, 2) == 1

In this version of unit_test.py, the first test evaluates if two plus two equals four. There’s also a new subtract function and a unit test to evaluate if three minus two equals one.

Run the test again:

pytest unit_test.py

Now our output is all successful tests:

collected 2 items
unit_test.py .. [100%]
========================================= 2 passed in 0.00s ==========================================

Practical unit testing example

Next, add the context of a realistic scenario for writing unit tests.

Imagine you’re implementing business logic on an ecommerce website. You need to write a function in Python to calculate the total price of an order. If your function doesn’t work properly, the website could charge customers the wrong amount of money when they place an order.

Because this code has real-world implications, you need to test it thoroughly before deploying it to production. Let’s write some unit tests to do that.

First, create a file called order.py and add this code:

def calculate_total_price(subtotal, discount=0, tax_rate=0.1):  
   if subtotal < 0 or discount < 0:  
       raise ValueError("Subtotal and discount cannot be negative!")

   discounted_price = subtotal - discount  
   tax = discounted_price * tax_rate  
   total = discounted_price + tax  
   return round(total, 2)

This function calculates the total price of the order by applying any discounts and taxes before rounding and returning the total. It also checks to make sure the subtotal and discount aren’t zero.

Next, create another file called test_order.py and add this code:

import pytest  
from order import calculate_total_price

@pytest.mark.parametrize("subtotal, discount, tax_rate, expected", [  
   (100, 10, 0.1, 99.0), #Standard case with $10 discount and 10% tax applied  
   (50, 5, 0.2, 54.0), #$5 discount and 20% tax applied  
   (200, 0, 0.05, 210.0), #No discount, 5% tax  
   (0, 0, 0.1, 0.0), #Edge case with zero subtotal  
])

def test_calculate_total_price(subtotal, discount, tax_rate, expected):  
   assert calculate_total_price(subtotal, discount, tax_rate) == expected

def test_negative_values():  
       #Ensure function raises error for negative values  
       with pytest.raises(ValueError):  
           calculate_total_price(-50, 5, 0.1)

       with pytest.raises(ValueError):  
           calculate_total_price(50, -5, 0.1)

This code tests four possible cases for the calculate_total_price function to make sure it is returning the totals correctly. It also uses the test_negative_values function to ensure the code raises a ValueError if the subtotal or discount is negative.

Run the pytest command:

pytest test_order.py

collected 5 items
unit_test.py .. [100%]
========================================= 5 passed in 0.01s ==========================================

All the unit tests passed. The calculate_total_price function is working as intended for all test cases.

What is mocking in unit testing?

In the previous examples, you tested the output of functions that perform simple arithmetic operations to predict the output. But what if you have a more complex function to test?

Mocks are fake objects used to track interactions. You can use them to verify behaviors, such as if a function was called, what arguments were passed to it, or the order of operations.

If you’re unit testing a function that interacts with another system, like an API or a database, you can use mocking to simulate the interaction. Mocking lets you send fake requests and generate mock data for testing. With mocking, you can isolate functions and test them without relying on any dependencies.

Here’s an example. You have a Python script that fetches data from a SQL database and updates a user’s email address. Create a file called db.py:

import sqlite3

def get_user(user_id):  
   conn = sqlite3.connect("database.db")  
   cursor = conn.cursor()  
   cursor.execute("SELECT id, name, email FROM users WHERE id = ?", (user_id))  
   user = cursor.fetchone()  
   conn.close()  
   return user

def update_email(user_id, new_email):  
   conn = sqlite3.connect("database.db")  
   cursor = conn.cursor()  
   cursor.execute("UPDATE users SET email = ? WHERE id = ?", (new_email, user_id))  
   conn.commit()  
   conn.close()

To verify that this code works, you’d need to be connected to a working SQL database so you could run the get_user and update_email functions, and make changes to the database.

In a real database, you would not want to interact with the data, you just want to test the function. You also want to verify that the function is working as intended in isolation without being connected to the database.

Using mocking, you can simulate these database calls to verify that your functions work without actually affecting the database. To continue, install a new framework called pytest-mock:

pip install pytest-mock

Create a file named test_db.py and add your unit testing code:

import pytest  
from db import get_user, update_email

def test_get_user(mocker):  
       mock_cursor = mocker.MagicMock()  
       mock_cursor.fetchone.return_value = (1, "Alice", "alice@example.com")

       mock_conn = mocker.patch("sqlite3.connect")  
       mock_conn.return_value.cursor.return_value = mock_cursor

       results = get_user(1)

       assert results == (1, "Alice", "alice@example.com")  
       mock_cursor.execute.assert_called_once_with("SELECT id, name, email FROM users WHERE id = ?", (1))  
       mock_conn.return_value.close.assert_called_once()

def test_update_email(mocker):  
   mock_cursor = mocker.MagicMock()

   mock_conn = mocker.patch("sqlite3.connect")  
   mock_conn.return_value.cursor.return_value = mock_cursor

   update_email(1, "newalice@example.com")

   mock_cursor.execute.assert_called_once_with("UPDATE users SET email = ? WHERE id = ?", ("newalice@example.com", 1))  
   mock_conn.return_value.commit.assert_called_once()  
   mock_conn.return_value.close.assert_called_once()

This example uses the mocker fixture to stand in for some real functions. Rather than connecting to the database with the real sqlite3.connect function, you’re using a mocked version.

The mocked version mimics the behavior of the real function without making actual changes to the database. It returns a mock object containing some simulated user data. Then you mock a SQL query that updates the user’s email address in the database.

Mocking enables developers to simulate complex scenarios in unit tests. Through mocking, you can simulate all sorts of code elements, including:

  • Functions, methods, and objects
  • Modules, class instances, and properties
  • Database calls and API calls
  • Environmental variables
  • File handling
  • Time functions

Best practices for unit testing

Here are some general-purpose best practices for writing unit tests:

  • Write isolated deterministic tests: Your tests should focus on the smallest functional units of code. Each test should be self-contained and not depend on other tests. Every test should have a single true or false outcome. Isolate from external dependencies through the use of mocking and stubbing.
  • Use descriptive test names: Your test names should describe what is being tested for and the expected outcome.
  • Test edge cases: Include unit tests to cover boundary values, error conditions, and edge cases.
  • Measure code coverage: Keep track of how much of your code is being tested through unit testing.
  • Use the right testing framework: There is no shortage of testing frameworks to choose from. Don’t reinvent the wheel by doing everything from scratch. Choose the framework that best complements the needs of your application and development environment.
  • Test often and automate: Your code should be re-tested whenever it changes. Tests can be run automatically when new code is merged or the application is rebuilt. A CI/CD pipeline facilitates this process.

Challenges in unit testing

Unit testing is a fairly straightforward process for validating whether a simple function implements logic correctly. Unfortunately, not all applications can be unit-tested so easily. Writing unit tests for complex scenarios, like validating the behavior of UI elements, or simulating specific user stories, may require advanced unit testing techniques or other forms of testing altogether.

Integration testing can complement unit testing, enabling developers to focus on the entire system’s behavior, rather than isolating specific functions for unit tests.

One of unit testing’s major challenges is flaky tests. A flaky test inconsistently passes or fails without changes to the code, making it unreliable. Common causes include concurrency issues, order dependencies, external resources, timing problems, and resource contention. Flaky tests reduce confidence in the test suite, slow down development, and waste time debugging.

Because unit tests are written to test expected behaviors, creating tests requires well-defined application requirements. If expectations and requirements change throughout the development lifecycle, it becomes difficult for developers to write new unit tests each time they shift.

Even if requirements are steady and well-defined, maintaining test suites becomes challenging as applications change. Developers must actively update their unit tests to reflect other changes in the codebase. Without regular updates, tests will become outdated and break, or become flaky and provide questionable results.

Too many tests — whether because they are inefficient, unnecessary, or simply bloated with the size of the project — can cause development workflows to slow down. Each test adds performance overhead for each build. Developers should curate and maintain their tests over time.

Unit testing strategies and test-driven development

Designing code with testability in mind encourages a modular approach. To be testable, functions should be small and independent. Each function should have a predictable behavior that developers can test for using expected inputs and outputs.

If a function is too long or has too many dependencies, unit testing becomes complicated. This can often be a clear indicator of underlying design issues. Functions that require elaborate mocking strategies to test may be better served by revisiting the design and finding ways to make the function independent.

Unit testing requires a thoughtful and planned approach to development. The Test-Driven Development (TDD) methodology prioritizes unit testing. In TDD, tests are written before the code. Developers then write the minimum amount of code necessary to pass those tests.

Automating unit testing with CI/CD

One of the most attractive features of unit testing is automation. Once a unit test is written, it can be run automatically as often as needed.

Automation assures developers that their code is always well-tested. Whenever a developer commits a change or builds the project, unit tests are executed automatically, validating that the new changes did not introduce unexpected behaviors.

The practice of continuous integration (CI) facilitates automated unit testing. CI enables developers to create a pipeline that automatically integrates, tests, and builds new code as it is added to the project. Developers can configure a custom CI/CD pipeline that automatically executes all unit tests and other tests.

To see this approach in action, check out our tutorial on unit testing serverless applications in a CI/CD pipeline.

CircleCI offers features that allow developers to run tests in parallel, minimizing the performance overhead from executing unit tests. Test splitting techniques leverage this parallelism to execute multiple tests in different testing environments simultaneously.

For projects that execute dozens or even hundreds of different tests for every build, test splitting is indispensable for creating efficient pipelines. It speeds up feedback cycles and gives developers the confidence to ship changes quickly.

As projects scale up and add more unit tests, handling the results and reports requires a strategic approach. Test Insights provides a dashboard for evaluating the efficacy of your test suite. You can quickly view success rates, identify slow-running tests, and track flaky test results.

Conclusion

Unit testing is an indispensable tool for any developer’s workbench. Understanding the principles of unit testing helps developers write better code. Unit tests are a powerful means of evaluating code quality, catching bugs, and ensuring code behaves as intended.

Automating unit testing through a CI/CD pipeline ensures that code is always tested throughout the development lifecycle. Developers can make changes with confidence as automated tests verify that the application is functioning as expected.

See how combining CI/CD and unit testing can improve your code by signing up for a free CircleCI account.

Copy to clipboard