Turn your manual testers into automation experts! Request a DemoStart testRigor Free

Unit Testing: Best Practices for Efficient Code Validation

Testing is paramount to ensure the quality of any product. You’ll see testing taking place across industries, where different types of testing are put to practice depending on the product under test.

One of the most common and heavily relied upon testing techniques is unit testing.

The Testing Pyramid

The testing pyramid helps guide how you write and prioritize different types of tests. If you take a look at the pyramid above, you’ll see that unit tests form the very base of this pyramid. This means that unit tests are the bulk of the testing exercise and, hence, need to be very effective.

What is Unit Testing?

Unit testing is a testing technique where individual units or components of an application are tested in isolation from the rest of the application. The goal is to validate that each unit of the software performs as expected. A unit can be a function, method, class, or module.

Here’s an analogy to explain the importance of unit testing…

Imagine you’re an auto mechanic working on a complex car engine. To ensure the engine runs smoothly, you test each individual component, such as the fuel injectors, spark plugs, and belts, before assembling them into the engine.

You might test a fuel injector by itself to ensure it delivers the right amount of fuel at the right pressure. If it doesn’t, you can fix or replace it without having to disassemble the entire engine. By doing this for every part, you can be confident that when you put the engine together, it will run smoothly without any hidden issues.

This is similar to unit testing in software development. By testing each small piece of code independently, you catch and fix problems early. This makes it easier to identify issues and ensures that when all the pieces are integrated, the system works as expected. This approach saves time and resources in the long run and leads to more reliable and maintainable software.

Here’s a code snippet to demonstrate what unit tests look like. But before seeing the unit tests, here’s the function for which the tests are written. This is a simple function that calculates the total price of items in a shopping cart.
def calculate_total_price(prices, tax_rate):
  """
  Calculate the total price including tax.
  :param prices: List of item prices
  :param tax_rate: Tax rate as a decimal (e.g., 0.05 for 5%)
  :return: Total price including tax
  """
  total = sum(prices)
  tax = total * tax_rate
  return total + tax
Unit tests for this function might look like this:
import unittest

class TestCalculateTotalPrice(unittest.TestCase):
  def test_calculate_total_price_no_tax(self):
    prices = [10, 20, 30]
    tax_rate = 0.0
    result = calculate_total_price(prices, tax_rate)
    self.assertEqual(result, 60)

  def test_calculate_total_price_with_tax(self):
    prices = [10, 20, 30]
    tax_rate = 0.1
    result = calculate_total_price(prices, tax_rate)
    self.assertEqual(result, 66)

  def test_calculate_total_price_empty_list(self):
    prices = []
    tax_rate = 0.1
    result = calculate_total_price(prices, tax_rate)
    self.assertEqual(result, 0)

if __name__ == '__main__':
  unittest.main()

What Makes a Good Unit Test?

How to do Unit Testing?

You can follow this approach for unit testing:

  • Identify the unit: The first step is to decide which piece of code to test. This could be a simple function that calculates an area, a more complex class that manages user data, or anything in between.
  • Write test cases: For the chosen unit, create test cases that represent different scenarios and expected outcomes. These test cases should cover both valid and invalid inputs to ensure the unit behaves correctly under various conditions. Read: How to Write Test Cases? (+ Detailed Examples).
  • Execute tests: Run the unit tests using a testing framework. These frameworks provide tools for setting up test environments, running the tests, and asserting expected results.
  • Analyze and fix: If any tests fail, it indicates an issue with the unit’s functionality. The developer must investigate the failing test, fix the underlying bug, and re-run the tests until all pass successfully.

Unit tests are usually automated for effective testing in Agile environments. Since unit tests are mostly code, you will see that different programming languages use different unit testing frameworks. Some popularly used tools are:

  • Python: unittest, pytest
  • Java: JUnit
  • JavaScript: Jasmine, Mocha
  • C#: NUnit, MSTest
  • Ruby: RSpec, MiniTest

Best Practices for Unit Testing

Writing good unit tests is a skill.

Luckily, this skill can be learnt by keeping in mind these best practices.

Focus on a Single Unit

Each test case should target a single unit of code (function, class, method). This will help you with test isolation and also simplify debugging.

Here’s an example of the right way to do it:
# Right: Testing a single function to calculate area
def test_area_calculation(self):
  result = calculate_area(5, 3)
  self.assertEqual(result, 15)

Even within a unit, try to stick to testing a single outcome in a unit test. Don’t test multiple functionalities in one test.

Here’s an example of what not to do:
# Wrong: Testing multiple functionalities in a single test (not focused)
def test_multiple_things(self):

  # This test mixes calculating area and data persistence which makes it less focused
  result = calculate_area(5, 3)
  self.assertEqual(result, 15)
	
  # Wrong: Also persists data in this test - not ideal for unit testing
  save_area_to_database(result)

Use the Arrange, Act, Assert Pattern

A commonly used format for writing unit tests, the AAA pattern stands for:

  • Arrange your prerequisites first. This means creating objects, initializing them, setting up the dependencies, etc.
  • Act on these objects. Write actions that the objects can do now so that you get outcomes that can be verified.
  • Assert the outcomes. Check that the operations performed in the ‘Act’ section give you the expected results. If not, then deal with the test status accordingly.

Write Independent Tests

Each test should be independent and not rely on the state or outcome of other tests. This ensures tests can run in any order and still produce consistent results.

Write Clear and Readable Tests

Use descriptive names and comments to explain the test’s purpose and expected behavior.

Here’s an example of the right way to do it:
# Right: Clear and descriptive test name with comment
def test_area_calculation_with_zero_values(self):
  """Tests area calculation with zero values."""
  result = calculate_area(0, 10)
  self.assertEqual(result, 0)
Avoid cryptic names and unclear logic. Here’s an example of what not to do:
# Wrong: Unclear test name and logic
def test_something(self):
  # Vague name and complex logic make it hard to understand
  area = calculate_area(data[0])
  if area > 10:
    # ... test logic here ...

Aim for Automated and Fast Tests

Your unit test suite should run multiple times during the development cycle. Hence, it is better to write these tests in a way that allows test automation. Moreover, unit tests are expected to be fast, not just due to the sheer volume of tests that need to be run but also because the test results are important to determine if the application is ready to move ahead, thus allowing for faster feedback cycles. Leverage a testing framework (like unittest) to automate test execution.

Here’s an example of the right way to do it:
import unittest

class TestAreaCalculation(unittest.TestCase):
  # ... test cases here ...

if __name__ == '__main__':
  unittest.main()
Don’t write manual tests or tests that rely on user input. Here’s an example of what not to do:
# Wrong: Manual test - not automated and requires user input
def test_area_manually():
  length = int(input("Enter length: "))
  width = int(input("Enter width: "))
  result = calculate_area(length, width)
  print(f"Area is: {result}")

You can make your unit tests fast by:

  • Avoiding unnecessary delays and time-consuming operations in tests.
  • Focusing on isolated units of code.
  • Optimizing test setup and teardown procedures.

Isolate Unit Tests by Using Mocking

Units of code interact with other units of code and even external services. If unit tests have to be fast, they cannot be waiting for a complete system boot to get a response for these external services. Hence, isolation is an important aspect of unit testing wherein the test works solely on the unit under test. In the case of external dependencies, mocking is used, which gives realistic yet fake responses that allow the test to carry forward. This allows the unit test to keep running without waking up the entire system just for a single response.

Here’s an example of the right way to do it:
# Right: Mocking a database call using mock library
from unittest.mock import patch

@patch('my_app.database.save_area') # Mock the database save function

def test_area_calculation_and_persistence(self, mock_save):
  result = calculate_area(5, 3)
  # Call the unit under test
  save_area_to_database(result)
  # Verify the mock function was called with expected arguments
  mock_save.assert_called_once_with(15)
As mentioned above, don’t directly interact with external dependencies in unit tests. This can slow them down and make them less reliable. Here’s an example of what not to do:
# Wrong: Directly interacting with database - not isolated
def test_area_calculation_and_persistence(self):
  result = calculate_area(5, 3)
  save_area_to_database(result) # Directly calls database function (not ideal)

Repeatable Tests

Your unit tests should not be flakey and produce the same results every time.

Use Appropriate Assertions

Every test framework offers assertions to let you validate the outcome. Don’t rely on print statements or implicit assumptions about success/failure. Read: Assertion Testing: Key Concepts and Techniques.

Here’s an example of the right way to do it:
# Right: Using assertion for clear verification
def test_area_calculation_positive(self):
  result = calculate_area(5, 3)
  self.assertEqual(result, 15) # Clear assertion for expected output
Don’t rely on print statements or implicit assumptions about success/failure. Here’s an example of what not to do:
# Wrong: Using print statements - not a clear assertion
def test_area_calculation(self):
  result = calculate_area(5, 3)
  print(f"Result: {result}") # Print statement doesn't provide clear assertion

Test Edge Cases

When writing unit tests, consider invalid inputs and boundary conditions to ensure the unit behaves correctly under various scenarios. Sticking to just the happy-path scenarios may cause problems when these edge cases turn up. Read: Positive and Negative Testing: Key Scenarios, Differences, and Best Practices. Here’s an example of the right way to do it:
# Right: Testing with zero and negative values (edge cases)
def test_area_calculation_edge_cases(self):

  # Test with zero values
  result = calculate_area(0, 10)
  self.assertEqual(result, 0)

  # Test with negative values
  with self.assertRaises(ValueError): # Expect an error for negative values
    calculate_area(-5, 3)

Maintain Unit Tests Regularly

Unit tests need to be up-to-date to accurately catch issues. Don’t duplicate code functionality within tests and refactor tests when the code they test changes significantly. Read: How to Write Maintainable Test Scripts: Tips and Tricks.

Here’s an example of what not to do:
# Wrong: Complex test logic that replicates code functionality (not ideal)
def test_user_login(self):
  
  username = "valid_user"
  password = "correct_password"
  
  # This repeats logic from the login function itself - not good practice
  if authenticate_credentials(username, password):
    login_result = True
  else:
    login_result = False
  self.assertTrue(login_result)

Use Suitable Naming Conventions

Test names should clearly describe what the test is verifying. This makes it easier to understand the purpose of each test. Read: Maximize Your Test Script Quality: Coding Standards and Best Practices.

Avoid writing names of functions and variables like this:
def test1():
  pass

def test2():
  pass
Instead, use descriptive names like this:
def test_add_positive_numbers():
  pass

def test_subtract_negative_numbers():
  pass

Test Expected Exceptions

Verify that the unit throws the appropriate exceptions when encountering invalid input or errors. Don’t assume the unit will handle errors gracefully. Explicitly test for expected exceptions. For example:
# Right: Testing for a specific exception with `assertRaises`
def test_division_by_zero(self):
  with self.assertRaises(ZeroDivisionError):
    divide(10, 0) # Call the unit under test (division function)

def divide(a, b):
  return a / b

Strike a Balance in Test Coverage

Aim for comprehensive test coverage without getting bogged down in testing every single line of code. Focus on critical functionalities and potential error paths. Don’t write overly granular tests that cover every single line of code. This can be time-consuming and not as valuable as focusing on core functionalities.

Conclusion

Unit testing, when done correctly, can be your most effective defense against bugs and regression issues. They also reduce the need for excessive functional testing. Due to the volume of unit tests, try to automate as many as you can to ensure that you can test at any time.

Frequently Asked Questions (FAQs)

Why is unit testing important?

Unit testing is important because it helps detect bugs early in the development process, ensures code reliability, facilitates easier code maintenance, and allows for safe refactoring.

How can I ensure my unit tests are independent?

Do not share the state between tests to ensure each test runs in isolation. Use setup and teardown methods to prepare the test environment for each test individually.

Why should test names be descriptive?

Descriptive test names make it easier to understand the purpose of each test, making the test suite more maintainable and readable for anyone who works on the codebase.

How often should unit tests be run?

Unit tests should be run frequently, ideally with every code change. This can be done by integrating tests into the continuous integration (CI) process.

What is the impact of slow unit tests?

Slow unit tests can disrupt the development workflow and discourage frequent testing. Keeping tests fast ensures they can be run frequently without a significant impact on productivity.

Why is it important to review and refactor tests regularly?

Regular review and refactoring of tests ensure they remain relevant, effective, and maintainable as the codebase evolves. It helps remove obsolete tests and adapt tests to code changes.

How can I integrate unit tests into my development workflow?

Integrate unit tests into your CI/CD pipeline to ensure they run automatically with every commit. This can be done using CI tools like Jenkins, Travis CI, or GitHub Actions.

Join the next wave of functional testing now.
A testRigor specialist will walk you through our platform with a custom demo.
Related Articles

Are Unit Tests a Waste?

If you’re someone in the tech industry, even if not a software engineer, then you must have heard about unit testing, seen ...