Skip to content

Learn

What is unit testing? A practical guide with examples

Learn what unit testing is, how it works, and why developers use it to catch bugs early, improve code quality, and build reliable software.

TL;DR

  • Unit testing verifies that individual components of code work as intended before integration into a larger system.
  • It is one of the earliest and most cost-effective ways to catch bugs in the software development lifecycle.
  • Tests can be run manually or automated, with automation strongly preferred in modern CI/CD pipelines.
  • Common frameworks include JUnit (Java), pytest (Python), NUnit (.NET), and Mocha (JavaScript).
  • Best practices include writing independent tests, following test-driven development (TDD), and testing early and often.

Testing is necessary for software development to produce stable and reliable programs and applications. As software grows to be more complex, ensuring every component works properly before it integrates into a larger system becomes increasingly important.

Unit testing is a fundamental testing technique designed for exactly such a situation. By verifying that each distinct component, or “unit,” of code functions as intended when used alone, unit testing minimizes problems before they become more serious and serves as a solid foundation for more extensive testing initiatives.

Unit testing has become a fundamental procedure with today’s focus on rapid and iterative development, enabling quicker and more dependable software releases.

This article will cover several topics related to unit testing, such as its types, main advantages, recommended procedures, language support, and practical implementation using tools like Tricentis qTest.

What is unit testing?

TL;DR: Unit testing validates individual components of code in isolation, ensuring each unit behaves correctly before being integrated into a larger system.

Unit testing is a software testing technique that involves testing individual units or components of a program in isolation to verify they operate as required.

The smallest portion of software that has the ability to carry out a specific function—typically a feature, method, or module—is referred to as a “unit” in this context.

By testing these individual components early on, developers can quickly identify and fix problems before they affect the larger system, saving time and resources.

Unit testing is a software testing technique that involves testing individual units or components of a program separately to make sure they operate as required.

Unit testing has roots in the early phases of software development, where verifying each component was important for building complex systems reliably.

As software engineering matured, —particularly with the emergence of agile approaches and continuous integration–unit testing became a cornerstone practice.

As Martin Fowler, the author of Refactoring: Improving the Design of Existing Code, puts it aptly, “Unit testing should be the first line of defense against bugs.” It is the earliest and least costly point to catch defects.

Preparing for unit testing

TL;DR: Preparing for unit testing involves clear requirements, breaking features into smaller units, and identifying dependencies to ensure proper isolation.

Before we can write a unit test, the feature specifications must be clear to the developer. That’s not to say that the developers need a large analysis of how they must implement the feature in code, but there should be a shared understanding of what must be implemented exactly.

A skilled developer will then split up the problem into smaller problems that they can solve individually in code.

Any dependencies of the code should also be clear at this stage, although the team may uncover new dependencies as they go forward. The developer will need to abstract these dependencies, as it’s not the unit test’s task to test these or to test the integration.

Selecting a unit testing framework

TL;DR: Selecting a unit testing framework depends on your programming language, ecosystem support, and how well it integrates with your development workflow.

If you haven’t chosen a unit testing framework yet, you can probably choose the framework that is most popular in your programming language:

  • .NET: NUnit or xUnit.net
  • Java: JUnit
  • PHP: PHPUnit
  • JavaScript / Node.js: Mocha

There are many alternatives that allow you to write test code in whatever programming language you prefer, so you should be able to find one that fits your preference.

Strategies for unit testing

TL;DR: Unit testing strategies typically include test-driven development (TDD) or code-first approaches, chosen based on design clarity and development style.

When developers need to write new code alongside its tests, they generally tend to follow one of the two strategies. The test-driven development (TDD) approach is to have your tests drive the design of your code base. This means you write your tests first.

The idea is to write a failing test first, then make it succeed by implementing the function correctly, and finally refactoring your code if necessary.

Test-driven development forces you to think about the public API of your function or class before you dive into the implementation.

It makes you think about test cases, the happy flow, and the edge cases.  Adding several unit tests, your implementation might still have room for improvement, and TDD surfaces those naturally

In the end, TDD gives you a modular code base with highly cohesive and loosely coupled components.

The alternative is to write your production code first and then write the unit tests.

This can sometimes be useful if the developer is still exploring the feasibility of a certain implementation. The implementation might still change several times, and writing unit tests would be a waste of time in that case.

A possible drawback could be that you end up with an implementation that’s difficult to test with unit tests.

The TDD approach is recommended for developers who find it harder to write loosely coupled code. At a higher level of experience, the code-first approach can also be successful.

Techniques for running tests

TL;DR: Effective unit testing relies on mocks, stubs, and fakes to isolate components and avoid real interactions with external systems.

As we mentioned, unit tests should test a small unit of code. This means it should be isolated from other units of code. But all these individual units inevitably must integrate to implement a certain feature.

To make matters worse, the unit you’re trying to test may have to call a database, read a file, or interact with an external web service. You don’t want to make these calls in your unit tests. This is where mock objects, stubs, or fakes come into play. 

Mock objects, stubs, and fakes let you test components in isolation. You provide a fake implementation to the unit you want to test, control its behaviour, and verify if it was accessed correctly.

This makes your unit test more of a white box test than a black box test—your test now tests parts of the inner workings of the unit. If the implementation changes significantly, you’ll also have to change your test. That tradeoff is still more valuable than having no test at all.

Best testing tools

TL;DR: The best testing tools offer strong community support, seamless IDE and CI integration, and consistent updates for long-term reliability.

Choosing the best testing tool is not only about technical capability. Consider the full picture:

  • Community support
  • Integration with your editor or IDE
  • Integration with your build server
  • Documentation quality
  • Update frequency and maintenance

Unit testing libraries mentioned above (JUnit, NUnit, xUnit.net, PHPUnit, Mocha, etc.) are excellent choices for most projects.

Not everything can be covered with unit tests, of course.  For other testing needs, good options include JMeter and k6 for load testing, and Selenium for web application end-to-end testing.

Example of a unit test in Python

TL;DR: A basic unit test uses assertions to compare expected and actual outputs, ensuring a function behaves correctly under different inputs.

Here’s a simple Python example that demonstrates unit testing. The function below determines whether a given number is even:

# Function to test
def is_even(number):
    return number % 2 == 0

# Unit test for the function
def test_is_even():
    assert is_even(4) == True, "Should be True for even numbers"
    assert is_even(3) == False, "Should be False for odd numbers"

# Run the test
test_is_even()

print("All tests passed.")

As a unit test in this example, test_is_even() verifies that the method is_even() returns False for an odd input and True for an even input. Before the function is used in a bigger program, this test makes sure that it operates as expected.

Test first

Here’s another very simple example using TDD. Suppose we want to write a function that handles general errors in a NodeJS web application (written in TypeScript). We know the specification is to return a HTTP 500 status code. This is our test:

import "mocha";
import { expect } from "chai";
import * as httpMocks from "node-mocks-http";
import { handleGeneralError } from "../../src/handleGeneralError";

describe("handleGeneralError", () => {
  let req: httpMocks.MockRequest;
  let res: httpMocks.MockResponse;

  beforeEach(async () => {
    const error = new Error();
    await handleGeneralError(error, req, res);
  });

  it("should set the status to a HTTP 500", () => {
    expect(res.statusCode).to.equal(500);
  });
});

A few things to note:

The “handleGeneralError” function has two dependencies: a Request and a Response object from the ExpressJS web framework. Because these are quite complex objects, we’re using a library (“node-mocks-http”) to create fake implementations.

We pass these dependencies and an error to the function, then verify the statusCode of the Response has been set to 500.

If we run this, the test fails because the function doesn’t yet exist or because it doesn’t contain the correct implementation. A failed test is useful, as we’ll see later.

Implementing the “handleGeneralError” function is easy:

function handleGeneralError(error: any, req: Request, res: Response): void {
  res.status(500);
}

If we now run the test, we’ll see that the test passes.

Adding a new specification

What if we want to return a HTTP 502 in case a downstream service timed out, but only if we’re OK with exposing these internal workings?

We could now write a test to verify that we get a HTTP 500 in case the “error.expose” value is set to “false” (I removed the previous test for brevity):

import "mocha";
import { expect } from "chai";
import * as httpMocks from "node-mocks-http";
import { handleGeneralError } from "../../src/handleGeneralError";

describe("handleGeneralError", () => {
  let req: httpMocks.MockRequest<Request>;
  let res: httpMocks.MockResponse<Response>;

  beforeEach(async () => {
    const error = new Error();
    (error as any).code = "timedout";
    (error as any).expose = false;

    await handleGeneralError(error, req, res);
  });

  it("should set the status to a HTTP 500", () => {
    expect(res.statusCode).to.equal(500);
  });
});

When we run this test, it passes. But it would also pass without changes to our “handleGeneralError” function because either our test is wrong or our code is wrong.

The test sets the error code to “timedout,” whereas the production code expects “timeout” (without the “d”).

This illustrates why a failing test first matters

If your test passes without any code changes, there might be a problem with the test itself. If you’re writing tests for existing code, the dynamic is different—the test will pass immediately.

In that case, temporarily remove or alter the code under test to confirm the test correctly fails without it.

After combining tests, run a code coverage tool. Coverage tools show which lines your tests exercise. Full line coverage doesn’t guarantee complete logic coverage; one still needs to reason carefully about the code paths.

Debugging with unit tests

Another example where unit tests shine is debugging. Say an end user filed a bug and, after looking at the logs, you see that it’s an input validation error. The client is providing the wrong input to your HTTP endpoint which causes the following error object to be thrown:

{
  "validationErrors": [
    {
      "field": "username",
      "error": {
        "description": "Username is required",
        "code": "username_required"
      }
    },
    {
      "field": "phone",
      "error": {
        "code": "phone_regex_mismatch"
      }
    }
  ]
}

We expect a HTTP 400 but see a 500. This is our function:

function handleValidationError(error: any, req: Request, res: Response): void {
  res.status(400);

  const body = error.validationErrors.map((e) => {
    return {
      field: e.field,
      error: e.error.description
    };
  });
}

It might seem obvious where the error lies, but real-life scenarios aren’t always clear. However, if we can reproduce the bug in a unit test, we can do some debugging and find the issue. This is our test:

import "mocha";
import { expect } from "chai";
import * as httpMocks from "node-mocks-http";
import { handleGeneralError } from "../../src/handleGeneralError";

describe("handleValidationError", () => {
  let req: httpMocks.MockRequest<Request>;
  let res: httpMocks.MockResponse<Request>;

  beforeEach(async () => {
    const error = {
      validationErrors: [
        {
          field: "username",
          error: {
            description: "Username is required",
            code: "username_required"
          }
        },
        {
          field: "phone",
          error: {
            code: "phone_regex_mismatch"
          }
        }
      ]
    };

    await handleValidationError(error, req, res);
  });

  it("should set the status to a HTTP 400", () => {
    expect(res.statusCode).to.equal(400);
  });
});

By debugging it, we found that our code can’t handle a validation error without a “description” field. We can easily fix this by changing our code to this:

function handleValidationError(error: any, req: Request, res: Response): void {
  res.status(400);

  const body = error.validationErrors.map((e) => {
    return {
      field: e.field,
      error: e.error?.description // we changed this line to handle missing descriptions
    };
  });
}

Continuous integration

Once we have our unit tests in place, we should commit them to source control so other team members can run them. This is continuous integration—sharing new code continuously so that team members can profit from the value of our tests.

If they accidentally make breaking changes to our code, the unit tests will notify them.

Types of unit tests

TL;DR: Unit tests can be categorized as white-box, black-box, or gray-box, with automated testing being the most widely used in modern development.

Unit tests may be automated or manual. Automated approaches are far more common as they are faster and more accurate, but some developers prefer a manual approach. Here are the basic types:

  1. White box testing, in which the functional behavior of the code is tested by developers who have written it or are familiar with the code. The purpose of white box testing is to validate execution.
  2. Black box testing, in which testers who are not privy to the internal functionality of the code test the user interface, inputs, and outputs.
  3. Gray box testing is a combination of white and black box approaches where testers are partially aware of the functionality of the code.

Unit tests typically have three stages:

  1. Preparing and reviewing the unit of code
  2. Making test cases and scripts
  3. Testing the code

types of qa testing

How to perform unit testing

TL;DR: Performing unit testing involves identifying the unit, writing test cases, executing them, and iteratively fixing issues based on results.

Unit testing is carefully examining each tiny code piece or function to make sure it operates as expected.

Because the procedure is well-organized and simple, you can identify problems early on before they have an impact on the larger application. This is a detailed guide to assist you in carrying out unit testing efficiently:

1. Identify the unit to test

Choose the particular function or technique you wish to test first. A function or a method that carries out a single action could be considered a “unit” of code.

2. Write test cases

Create test cases describing the desired behavior for every unit. Make sure the unit can handle a variety of inputs by identifying both common and edge cases.

Create test cases for standard numbers, zero as an input, and potential negative values. For example, if you are evaluating a function that divides two numbers.

3. Use a testing framework

Select a testing framework that is suitable for the programming language you use. For example, JUnit for Java or unittest/pytest for Python. These frameworks offer systematic methods for developing, arranging, and executing your test cases.

4. Write the test code

Write code that calls the unit (function or method) with various inputs using the framework. To determine whether the function operates correctly, the test should compare the predicted and actual outputs.

5. Execute the test cases

Use the selected framework to run the test cases. To help you determine whether the unit behaves correctly, each test will either pass or fail.

6. Analyze test results

Check any tests that didn’t pass to find out why. Fix any bugs in the code, make any necessary changes to the test cases, and then run them again to make sure the problems have been fixed.

Fix any bugs in the code, make any necessary changes to the test cases, and then run them again to make sure the problems have been fixed.

Automated unit testing: faster, scalable, and continuous

TL;DR: Automated unit testing improves speed, scalability, and consistency by running tests continuously within CI/CD pipelines.

Manual unit testing can be time-consuming, especially as applications grow. Automated unit testing solves this by allowing developers to write test scripts once and run them continuously—saving time, reducing errors, and enabling faster releases.

Why Automate Unit Tests?

Automating unit tests brings major benefits:

  • Faster feedback during development
  • Immediate detection of regressions
  • Higher test coverage across edge cases and inputs
  • Reduced manual effort over time
  • Consistency in test execution

In Agile and DevOps workflows, automated unit testing is critical for continuous integration and continuous delivery (CI/CD). It ensures every code change is tested automatically, helping catch bugs before they reach production.

Example 1: Python with pytest

# even_check.py
def is_even(n):
    return n % 2 == 0


# test_even_check.py
from even_check import is_even

def test_is_even():
    assert is_even(2) == True
    assert is_even(3) == False
    assert is_even(0) == True

To run it automatically, you can just execute:

pytest

pytest will automatically discover and run the test. This can be added to a CI pipeline using tools like GitHub Actions, GitLab CI, or Jenkins.

Example 2: Java with JUnit

// MathUtils.java
public class MathUtils {
    public static boolean isEven(int number) {
        return number % 2 == 0;
    }
}

// MathUtilsTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class MathUtilsTest {
    @Test
    public void testIsEven() {
        assertTrue(MathUtils.isEven(4));
        assertFalse(MathUtils.isEven(5));
    }
}

JUnit tests like this can be executed automatically during builds using Maven or Gradle, and integrated into CI servers like Jenkins or Bamboo.

When to automate unit tests

1. When functions are stable and unlikely to change frequently

Automating tests for stable code ensures long-term reliability. Once a function is well-defined and unlikely to undergo frequent changes, writing automated tests helps catch any unintended side effects from future changes elsewhere in the codebase.

Example: A utility method like is_even(number) or a date formatting function is perfect for automation. Once correct, its logic rarely changes.

2. When the code is business-critical or high-risk

Automate unit tests for logic that directly supports your core functionality or carries financial, security, or operational risk. These areas must be protected with repeatable tests to avoid major failures in production.

Example: A pricing calculation in an e-commerce platform or authentication logic for user login should always be covered by automated tests.

 3. When you’re working in a CI/CD environment

In continuous integration/continuous deployment (CI/CD) pipelines, automated unit tests serve as a gatekeeper. They run automatically on every commit or build and prevent faulty code from moving downstream.

Manual testing simply can’t keep up with the speed and frequency of deployments in CI/CD environments.

Example: Teams using GitHub Actions, Jenkins, or GitLab CI can run all unit tests automatically after every code push.

Every time you change code, you risk breaking existing functionality

4. When you want to minimize regression risks

Every time you change code, you risk breaking existing functionality. Automated unit tests provide immediate feedback if a new change introduces a bug. This reduces the likelihood of regressions and supports safer refactoring.

Example: If you refactor a payment validation method, your existing automated unit tests will catch any new logic errors right away.

In short, automated unit testing is ideal when stability, reliability, and speed matter.

Benefits of unit tests

TL;DR: Unit tests help catch bugs early, improve code quality, reduce long-term costs, and make debugging and maintenance significantly easier.

Unit tests enable software development teams to:

1. Save time

Performing frequent unit tests will save time during regression testing.

2. Make easier, faster fixes

It’s easier for developers to fix bugs in a unit of code when they’re still immersed in it rather than long after they have moved on to other parts of the software, or when defects are discovered during system testing or acceptance testing.

3. Create more reusable code

Unit testing facilitates more modular code, making it easier to reuse.

4. Lower cost

The cost of fixing issues during unit testing is much less than the cost of repairing defects found during acceptance testing or when software is in production.

5. Perform easier debugging

With unit tests, only the latest changes need to be debugged when a test fails.

6. Produce higher-quality code

Unit testing significantly enhances code quality and helps developers find the smallest defects before moving to integration testing.

Disadvantages of unit testing

TL;DR: Unit testing has limitations, including lack of UI and integration coverage, time investment, and reliance on developer expertise.

Unit testing has drawbacks that could reduce its value. Even though it’s useful for finding errors early and enhancing code quality, the following are some major drawbacks:

1. Doesn’t cover non-functional testing

Only individual functions and logic are the focus of unit testing. It’s unable to evaluate non-functional elements that are essential to the success of applications in real life, such as scalability, security, or performance.

2. Limited for user interface (UI) testing

Unit testing is not appropriate for testing user interactions or visual aspects because it focuses on code functionality. Understanding how users interact with the application is necessary for UI testing, which is outside the scope of unit testing.

3. Time-consuming for large projects

The development process may be slowed down by the time-consuming nature of writing and maintaining unit tests, particularly for big projects with several units.

4. Limited in detecting integration issues

A solid understanding of testing concepts and code is necessary for efficient unit testing. Writing relevant tests may be difficult for less experienced developers, which could lower the quality of the testing.

5. Requires skilled developers

As unit tests tend to focus on imaginary situations, they might not accurately represent how the program will be utilized in actual use cases. This could result in unforeseen problems after deployment. You might require skilled developers to write test cases.

Unit tests are best performed continuously and frequently.

Unit testing best practices

TL;DR: Best practices include writing independent tests, testing early and often, maintaining clarity, and separating test code from production code.

  1. Make sure that unit tests are independent of one another. If one unit of code is changed or enhanced, unit test cases will not be affected.
  2. Test one piece of code at a time. This practice will simplify code changes or refactoring.
  3. Create clear and consistent naming conventions for unit tests. This will help eliminate confusion as the volume of written unit tests expands.
  4. Fix bugs in each unit before proceeding to the next phase of software development. Defects found during unit testing must be fixed before moving on to integration testing.
  5. Write tests that expose defects before fixing them. Before fixing a bug, it’s important to write or modify a test that will reliably expose the defect. That way your unit test can catch the defect in future iterations if it is not properly fixed.
  6. Test early and often. Unit tests are best performed continuously and frequently.
  7. Separate test and production code. When performing unit testing, ensure that the test code is not deployed with the source code in the build script.

2 women looking at a laptop

Unit testing with Tricentis qTest

TL;DR: Tricentis qTest enables centralized test management, improves collaboration, and supports scalable automation across development teams.

Tricentis qTest provides software test automation tools that help enterprises and development teams prioritize quality, develop more reliable software, and increase speed to market. qTest offers a suite of Agile testing tools designed to improve efficiency and ensure collaboration that enables teams to release the best software.

With qTest, developers and testers can centrally manage open-source frameworks and commercial test automation tools for unit testing, functional testing, integration testing, exploratory testing, and many other testing protocols.

Tricentis qTest enables development teams to:

  1. Create better software faster. qTest optimizes and orchestrates end-to-end quality across teams, projects, and applications to accelerate the speed of each release.
  2. Scale automation. qTest centralizes test automation management and integrates with open-source and proprietary test automation tools.
  3. Improve collaboration. qTest makes developer-tester alignment easier with real-time integration for testing with Jira at both the requirements and defect levels.
  4. Increase speed to market. qTest supports Agile methodology by allowing teams to use QA testing tools strategically, testing early and often, and getting to market faster.

The Tricentis platform also includes solutions for test automation, performance testing, data integrity testing, smart impact analysis, and solutions for SAP, ServiceNow, Snowflake, Oracle, and Salesforce testing.

Intelligent test automation software screens

Tricentis Tosca

Learn more about intelligent test automation and how an AI-powered testing tool can optimize enterprise testing.

Author:

Guest Contributors

Date: Mar. 21, 2026

FAQs

What is unit testing?

Unit testing is the first level of software testing. In a unit test, an individual component of code is tested to ensure that it works as intended.

What are the benefits of unit testing?
+

Unit testing allows developers to identify defects in code at a point in the software development lifecycle when it is easiest and least costly to fix them.

What is unit testing vs. integration testing?
+

Unit testing is the first level of software testing. Integration testing is the second level and is conducted after testing individual units. During integration testing, units or modules are combined to test their functionality as they work together. Integration testing is typically performed by testers rather than developers, and maintaining integration test cases is more expensive than unit testing.

What is a parameterized test?
+

The same test can run with many sets of inputs when it is parameterized. Checking a function’s behavior with different values is especially helpful because it may identify edge cases or assure consistent performance across data ranges. Parameterized tests avoid repetitive coding and increase test case efficiency by defining input parameters in an organized manner.

What programming languages offer built-in support for unit testing?
+

Unit testing is supported by libraries and frameworks in many new programming languages. For instance, NUnit for .NET applications, Java gives JUnit, and Python comes with the unittest package. These technologies facilitate the design and execution of test cases and standardized testing methodologies, and they frequently work well with IDEs to enable effective debugging.

What are the prerequisites of unit testing?
+

A clear understanding of the functionality of the unit, a well-defined code structure, and easily accessible test environments are all necessary for performing effective unit testing. Additionally, testers must be well versed in the testing framework and programming language being used. The test accuracy can be further improved by setting up a dependable test environment with the required stubs and dummy data.

How does unit testing support CI/CD pipelines?
+

Unit tests run automatically on every code commit in a CI/CD pipeline, acting as a gatekeeper that prevents faulty code from advancing to staging or production. This enables teams to detect regressions immediately, maintain code quality at speed, and deploy with greater confidence.

What is the difference between unit testing and functional testing?
+

Unit testing verifies that individual components work correctly in isolation. Functional testing verifies that the application behaves correctly from the user’s perspective, testing end-to-end features or workflows rather than isolated functions.

You may also be interested in...