Unit testing 101: Making sure your code actually works
Great developers don't write good code: they write good tests.
In light of the CrowdStrike fiasco, I discussed how proper testing could've prevented the massive software outage. Today, I'll discuss one of the primary types of software testing.
You can't write good code without being prepared to test it. Unit testing is one of the primary ways to ensure you've got quality code.
Once you've started writing code, you can learn to write unit tests. While other forms of testing often fall on quality assurance engineers, unit testing is most often your responsibility as a developer. This is because you'll have the best understanding of how the code you wrote was intended to behave.
Today, I'll explain best practices of unit testing so you can hit the ground running with unit tests that help improve your code.
What is unit testing?
Unit tests ensure that the different parts of your application work as intended. They focus on units: the smallest parts of your program that can be independently tested. Units can include functions, methods, classes, or more.
By focusing on these small, individual components, unit testing helps identify and resolve problems before they cause larger issues in your application. Unit testing can't guarantee that your application will be entirely free of bugs after deployment, but it can help catch issues early in the process.
Why do we unit test?
There are several advantages to unit testing:
Smoother debugging: By testing individual units, you can catch bugs before they spread to other parts of the application.
Encourages loosely coupled code: Unit testing encourages you to write code where different parts are less dependent on each other, which is a best practice in software development.
Minimizes code regression: Code regression refers to bugs that occur when new code interferes with existing functionalities. After modifying or extending your code, you can rerun unit tests to ensure the changes don't break existing functionality.
Increasing test coverage
The amount of our code that has been tested is a metric we call test coverage. By focusing testing on each small component, unit testing helps ensure higher test coverage.
Test coverage can consist of:
Function coverage: The percentage of functions that have been tested.
Statement coverage: The percentage of individual statements in your code that have been tested.
Path coverage: The percentage of different paths or branches in your code that have been tested.
Generally, we want to maximize test coverage, however, 100% test coverage is neither practical or achievable. Full coverage can require more time and resources than available, and it's also theoretically impossible for complex systems. This being the case, it's enough to strive for the highest coverage possible within your constraints.
5 unit testing best practices
1. Test names should be descriptive
Your test names should clearly state what the test is meant to check.
This is the difference between the names:
test_1
test_user_login_with_valid_credentials
“test_1” does not provide any context about what is being tested. Meanwhile, “test_user_login_with_valid_credentials” clearly indicates that the test is checking if a user can log in with valid credentials. (I think you can draw the conclusion about which is the better test name.)
Consistent naming conventions help make your tests easier to understand and maintain. This practice also supports code readability, which is crucial for long-term project sustainability (and the sanity of fellow developers working in the same codebase).
2. Tests should be deterministic
False positives and negatives are common in software testing, so you have to be diligent to avoid them. This is done by writing deterministic tests.
Deterministic unit tests behave the same way every time they are run, assuming the code hasn't changed. This consistency helps ensure that test results are reliable.
3. Follow the Arrange, Act, Assert (AAA) Protocol
The AAA protocol is a recommended approach for structuring unit tests. AAA improves your test’s readability by giving it a logical flow.
The AAA protocol is a structured approach that organizes unit tests and enhances test readability.
You can use the AAA protocol to structure your unit tests with the following steps:
Arrange: Arrange the conditions needed for your test
Act: Act on the unit you're testing
Assert: Assert or verify if the outcome was as expected
Here's an example of the AAA structure in Python, testing the absolute value function:
def test_abs_for_negative_number():
# Arrange
negative = -4
# Act
answer = abs(negative)
# Assert
assert answer == 4
4. Test a single use case per unit test
Each test should concern a single use case, ensuring that a specific method or function behaves as expected in that scenario. This makes it easier to locate the cause of a failure if a test doesn't pass.
5. Avoid logic in tests
The more logic in your tests, the higher the chance of introducing bugs into the tests themselves. Keep your test code simple by minimizing logic, such as conditionals or string manipulations.
Consider this test with logic:
def test_user_access():
if user_is_admin(user_id):
assert has_access_to_admin_panel(user_id)
else:
assert not has_access_to_admin_panel(user_id)
This test combines two different scenarios (admin and non-admin users) into one, making it more complex and difficult to maintain.
You can improve this by breaking it into two simpler, more focused tests:
def test_admin_user_has_access():
assert has_access_to_admin_panel(admin_user_id)
def test_non_admin_user_does_not_have_access():
assert not has_access_to_admin_panel(non_admin_user_id)
By separating the tests, each one becomes easier to understand and less prone to errors, as they focus on a single condition or behavior.
Unit testing frameworks
To get started, I recommend picking a testing framework for your given language. A unit testing framework provides tools to help you write, run, and manage your tests efficiently. If a test fails, the framework can capture the results or raise an alert.
These frameworks offer several useful features:
Test suite: A collection of related test cases that can be run together, making it easier to test multiple similar units.
Test runner: A tool that automates the execution of tests and reports the results.
Test fixture: A consistent setup for your tests, ensuring they run in a controlled environment every time.
There are many unit testing frameworks available, each suited to different programming languages. Some popular ones include:
PyUnit (Python)
CUnit (C)
Mocha (JavaScript)
JUnit (Java)
Great developers are avid test writers
…And if you're here, I'm sure you want to be a great developer.
By testing your code as you write it, you can reduce bugs and ensure your project progresses smoothly. Be sure to carry the best practices we learned today as you begin your foray into unit testing.
If you work with Java, you might consider diving into unit testing with our course, Pragmatic Unit Testing in Java 8 with JUnit 5. In this course, you'll learn how to use JUnit to write tests, automate their execution, handle test failures with assertions, and record results.
Happy learning!
- Fahim