Software unit testing is a software assessment technique that focuses on individual components or units of an application in isolation from the rest of the application. In any given software codebase, code elements such as functions, classes, or modules may be categorized as units, and each unit is tested separately to assert its functionality and intention. This means that unit testing’s main purpose is to verify that each unit-under-test is working correctly and as expected, independent of the other units. In this way, localized bugs can be detected and fixed early in the development process before they can negatively affect the overall system.
Principles of Effective Unit Testing
The templates and the unique characteristics of each unit test workflow will depend on the language, framework, and other factors, such as the code base architecture. In general, however, unit tests should be modular, self-contained, repeatable, and focused on a specific aspect of the code. An effective unit test should exhibit the following principles:
- Self-Contained – during testing, each unit or component should be tested individually in isolation from the rest of the code base. This means then that a unit should be tested without the dependencies of the other application parts. This isolation can be achieved through mocking and stubbing. Mocking means creating a fake version of an external or internal service to stand in for a real one to help run tests easily and reliably. Stubbing, on the other hand, is like mocking, but the stand-in fake emulates the behavior of the object rather than the object properties.
- Focused – a single test should assess only one thing at a time and focus on only one aspect of a unit’s behavior. This makes it easier to identify the source of any failures and helps ensure that tests are focused and effective.
- Fast – unit tests should be engineered to be as fast as possible. This is because if they are slow, the developers will not run them as frequently as they should, beating the whole purpose of having a test suite in the first place.
- Repeatable and deterministic – building up on testing one thing at a time, each unit test should be repeatable. This means that running a test numerous times should always produce the same results without any changes if there are no changes in the code being tested.
- Simple and Readable – an effective unit test must be easily readable by using descriptive and meaningful names. Test names should clearly and accurately describe what the test is checking. This makes it easier to understand the purpose of a test and can help identify the source of any failures. Proper naming conventions help to make the tests well-organized, effectively readable, and easily documentable.
- Test for all Scenarios – an effective unit test checks for expected results and error conditions. Tests should be written to check that a unit produces the expected results when given a valid input, as well as to check that it handles error conditions and invalid input correctly. This makes a unit test comprehensive and ensures it behaves correctly in all situations.
- Maintainable – Effective unit tests should be easy to modify and extend as the code they are testing changes. This can help ensure that tests remain relevant and useful over time.
- Test Continuously – Effective unit tests should be written very early in the development process and should be executed frequently as code develops. In essence, unit testing should be done and updated throughout the software’s life cycle so as to catch bugs early and continuously, as well as to ensure code integrity is maintained.
- Use assert statements – An assert statement is a statement that checks a condition and raises an error if the condition is not met. Assert statements are a common way to verify that a unit is producing the expected results.
When should I not perform unit testing?
There are no hard-and-fast rules for when unit testing should or should not be performed. In some situations, unit testing may not be the best approach or may not provide worthwhile value. However, here are some situations when unit testing may be unfeasible and/or ineffectual:
1. Working with Legacy Code
Most legacy code, especially which was written without testability in mind, can be very difficult to write unit tests for. Some examples of legacy code are compiled binaries without any source code origin attached or old code that it’s hard to find compatible testing frameworks. In such a case, writing unit tests may be unfeasible, and thus developers should focus on either refactoring the code to be more testable or just focus on other higher-level tests like integration testing.
2. Working with heavy external dependencies
If the code you wish to test is tightly coupled with other components, unit testing may not be effective. In some cases, integration or system tests may be more appropriate, as these tests can better verify the interactions between different components. Some good examples of this are when dealing with network services and databases. It is more appropriate to test for functionality through integration testing as opposed to unit testing.
When the cost is too high to justify unit testing in
some specific cases, especially when dealing with applications with a short lifespan, the effort and cost required to write and maintain a unit test suite may be greater than an application’s cost and benefits. In such a case, unit testing should be abandoned altogether or testing only the critical components. In other cases, too much time is wasted on fixing failed tests as opposed to writing the software code. In such a case, writing tests for only specific units is recommended.
3. When there is a time and budget constraint
If time and budget are critical constraining factors for a client, then unit tests may be skipped in order to beat a deadline. However, care should be taken, especially if the software code keeps growing, since this can result in increased code complexity, costs, and risks in the long run.
4. When dealing with non-critical code
If code is not critical to the functional capabilities of software, then unit testing may be omitted. In some cases, unit testing can be avoided on units that are not expected to change throughout the software’s life cycle though this is hard to identify most of the time.
The final decision on whether to conduct unit testing should be based on the testing goals, the available resources, and the timeline. The best approach to testing depends on the situation, and there is no one-size-fits-all solution.
What are common mistakes developers make when unit testing?
Here are a few common mistakes that developers make when writing unit tests:
1. Depending on external services
Most of the time, the code you write does not work in isolation and is heavily dependent on external services and APIs. In unit testing, your tests are not responsible for failures in third-party services, and that is why a stand-in mockup is preferred for testing rather than the actual external service. External services should have been tested by their own developers, and thus you should focus on your code and not external code.
2. Over-dependence on code coverage
Code coverage (link to the other blog) testing is a software testing technique that measures how much of the source code of a program is executed when the tests are run. Over-dependence on the metrics of code coverage (link to code coverage metrics blog) can give skewed unit tests. You should take code coverage as a metric to show what has been tested and what has not been tested. Then using the metrics, you should make unit tests that check that all your written tests are accurate and cover different scenarios for the same code unit. Stop trying to hit 100% test coverage when 50% of those tests are not thorough enough. Test the critical parts of your code and make sure they are comprehensively tested.
3. Dependence on implementation details
Tests should focus on a unit’s behavior and the expected results it produces rather than the specific implementation details of the code. Testing implementation details can make tests fragile and difficult to maintain. This is because any change in implementation will break the unit test even if the unit’s behavior has not changed.
Not Testing for edge cases l
ike we have discussed in the principles of effective unit testing, unit tests should test for all scenarios. These scenarios include edge cases. Edge cases are situations and inputs that are outside the normal range of agreed values and characteristics. For example, inputting a string or infinite number in an integer field. Such cases should be tested; otherwise, you risk missing critical software bugs that occur only in those edge situations.
4. Testing multiple things at a time
A single test should only focus on one aspect of a unit’s behavior. Testing too much in one test can make it difficult to identify the source of any failures and can lead to tests that are too complex and hard to maintain. A unit test should focus on a particular unit, and if your code does not allow you to do that, modifying your tests is not the way to go. You should refactor the code function instead and split it not more manageable units for easy testing. Tests should be self-contained and not rely on the state of other tests. This ensures that tests can be run in any order and still produce accurate results. Alternatively, if tests are dependent, one should make sure that tests check behaviors and not specific details.
5. Writing your tests after the fact
Unit testing is a continuous process that happens when writing the code and most preferably, before development commences. This is because you are thinking about what the code can do and should do at those times. However, if you write tests after the fact, especially after deployment, then you will introduce biases into the testing code. Testing a code unit before it is fully developed helps a developer to question the code logic and write more meaningful tests.
6. Not updating tests when code changes
Whenever code changes, unit tests should be updated to ensure they are still valid and relevant. Sometimes you can change code that changes the logic of a code unit which does not break the old unit test. This is very dangerous since your tests will pass even if they are not complete. At this juncture, you need to use code coverage tests to identify the lines of code that have been tested and update the unit tests accordingly.
7. Not testing for error conditions
Tests should be written to check that a unit produces the expected results when given valid input, as well as to check that it handles error conditions and invalid input correctly. Failing to test for these scenarios can leave bugs undiscovered.
8. Not using enough assert statements
Assert statements are a common way to verify that a unit is producing the expected results. Failing to use assert statements can make it difficult to identify the source of any failures and can lead to tests that are less effective. Emphasizing again, that while adding many asserts is useful, you should try avoiding asserting on implementation-specific details such as certain internal variables.
By following these guidelines, you can increase the likelihood that your unit tests are effective and useful in detecting and addressing bugs in your code.
What tools can I use to start or improve unit testing?
Now that you know what and what not to do when writing unit tests, the next question would be what tools to use for unit testing. Unit testing tools are language-specific and below is a list of testing frameworks and modules for different programming languages:
Python Tools
- Pytest framework – This (https://docs.pytest.org/en/7.2.x/) is a third-party testing library for Python that is used mainly to write APIs test cases that make it easy to write small readable tests that can scale to support complex functional testing of applications and modules.
- Behave Framework – This (https://behave.readthedocs.io/en/latest/) is a Python based behavior-driven development tool that allows for an environment where testers, developers, business analysts, and other stakeholders of the project can contribute towards the software development.
- Doctest Framework – Doctest(https://docs.python.org/3/library/doctest.html) is a builtin python module which is a lightweight testing framework that can read test cases from code documents and docstrings making it an efficient test automation framework.
- Robot Framework – This (https://robotframework.org/) is an open-source python automation framework that uses a keyword-driven software testing technology used for Acceptance Testing, Acceptance Test-Driven Development, and Robotic Process Automation.
- Unittest Framework – This (https://docs.python.org/3/library/unittest.html) is a Python standard library module that supports test automation, aggregation of tests into collections and sharing of setup and shutdown code for tests.
- Coverage – This (https://coverage.readthedocs.io/en/7.2.1/) is a python tool that is used to measure code coverage tests in a python program. It monitors the testing of your application and reports on which parts of your code have been tested and those that have not been tested.
- Tox – This (https://tox.wiki/en/latest/) is a visionary tool in development that aims at automating and standardizing testing in Python by easing the packaging, testing, and release of python software.
- Nose2 – This (https://docs.nose2.io/en/latest/) is a test automation framework that extends unittest and enables auto-discovery of test cases and documentation collection.
Javascript tools
- AVA framework – This (https://github.com/avajs/ava) is a test runner for NodeJS that allows developers to run JavaScript tests concurrently
- Jasmine – This (https://jasmine.github.io/setup/python.html) is a behavior-driven development framework for testing Javascript Code. Mocha – This(https://mochajs.org/) is a popular automated testing framework running in NodeJS and in the browser for running tests asynchronously.
- Jest – This (https://jestjs.io/) is a javascript testing framework that is mainly used and designed to work with React and React Native web applications. It has, however, been extended to work with Vue, Angular, and Typescript.
- Testing Library – This (https://testing-library.com/) is a family of test packages that aids in testing User Interface components in a user-centric manner by querying and interacting with the DOM nodes, similar to how a user finds elements on a web page.
- TestDouble – This (http://testdouble.github.io/testdouble.js/) is a JS library that provides stubs, mocks, fakes, and spies to replace code for the purpose of isolating, mocking, and testing applications.
- Chai – This (https://www.chaijs.com/) is a Behaviour Driven Development and a Test Driven development assertion library for NodeJS and browsers.
- Karma – This (https://karma-runner.github.io/latest/index.html) is an HTTP server launcher that generates a test runner HTML file allowing the user to execute JS code in multiple real browsers originally built for Angular JS.
- Sinon – This (https://sinonjs.org/) is a JavaScript Library that lets you replace the complicated parts of your code with stand-in mockups, test spies and stubs for unit testing with any other framework like Jasmine and Mocha
- Istanbul – This (https://istanbul.js.org/) is a test coverage testing tool that helps to track unit tests in a JavaScript codebase.
Cross Platform Tools
- Gauge Framework – This (https://gauge.org/) is a lightweight cross-platform behavior-driven test automation framework that allows developers to write end-to-end tests in markdown format. It supports Javascript, C#, Java, Python and Ruby.
- qodo – qodo is a test generation tool that has a VSCode extension developed with Python that analyzes your source code and auto-generates meaningful unit tests. It is still under development and is aimed to support Python, Javascript, Typescript and Java.
- Selenium – This (https://www.selenium.dev/) is an open-source automated testing framework that automates browsers and is used to check and validate web applications across different platforms and web browsers.
Tools in Other Languages
* Java: JUnit -JUnit (https://junit.org/junit5/) is a unit-testing framework for Java.
* .NET: NUnit – This (https://nunit.org/) is a unit testing framework for all .NET languages initially ported from Java’s JUnit.
* Ruby: RSpec – This (https://rspec.info/) is a unit testing framework for Ruby created for behavior-driven development usually used for testing applications in development and in production.
* PHP: PHPUnit – This (https://phpunit.de/) is a unit testing framework for PHP based on JUnit architecture.
* C++: cppunit – This is a unit testing framework for C++ based on JUnit architecture.