“Failure is Feedback and Feedback is the breakfast of Champions” – fortune cookie
Introduction
This is the second blog in a series focused on software tools, processes and principles behind receiving feedback.
In my first blog I discussed the importance of receiving feedback early and often throughout the software development lifecycle. Feedback is essential in delivering a product the customer actually wants.
In the following blog I will be focusing on the importance of unit tests and the benefits gained from utilizing them.
What Is A Unit Test
A unit test is automated test code, that invokes the smallest pieces of testable functionality (the unit) as possible. A unit of code is most commonly an individual function or method with one or more inputs and one output. Unit tests demonstrate how units of code work in isolation by invoking a unit in some way and verifying that the unit behaves as expected. In effect, the unit returns an expected value — or performs an expected action — when provided with a known input.
Code which has external dependencies can be isolated by replacing those dependencies with a test implementation or a mock object with predefined behavior. This allows the tests to focus only on the units of code being tested without depending on external behavior — making it much easier to find and resolve issues.
Why Write Unit Tests
The main purpose of writing unit tests is to validate that the code works as the developer intended. However, unit tests also provide the following additional benefits to software development teams:
Increased code confidence
Unit tests allow early detection of defects and issues. As unit tests are being developed and executed, whether they pass or fail, the developer receives instant feedback on the code they are writing. Once all the tests are complete and run successfully, developers should be confident that the code works as intended and the codebase has not regressed in quality.
Support for fearless refactoring
Unit tests are the first line of defense to make sure changes to code don’t break something else.
Dynamic documentation
Unit tests are an excellent form of documentation because they are continuously evolving with the code. Unit tests are the source of truth, allowing new developers to easily gain a basic understanding of the intended functionality of a unit and also helping existing developers recall all of a unit’s edge cases. In addition to writing new unit tests for new functionality it is important to keep current all existing unit tests. As the code changes, tests may also have to change.
Improved software design
In order to write good unit tests, your code needs to be modular, extensible, and loosely coupled. Testing should be a consideration in the design of the code. If something isn’t testable, it probably isn’t well structured either. Unit tests should be written as the code is developed so they drive software implementation and — ultimately — better design. Following a software development approach such as test-driven development (TDD), will help guide developers in creating a testable application.
Where Should Unit Tests Be Run
To ensure the code continues to behave as intended, unit tests should be run locally during development and prior to committing changes to the repository. Developers are provided with fast feedback every time the unit tests are run. If their changes broke a test they can find out quickly and resolve the issues prior to ever introducing a defect. Most modern IDEs can run the entire suite of tests in the click of a button or teams could create pre-commit hooks that run the unit tests prior to committing changes. Failing unit tests would prevent the commit from occurring.
Teams should also use a code coverage tool to measure whether their unit tests cover all the source code in the unit being tested. Code coverage tools can report whether each line has been executed, whether every path has been taken, and even whether each condition within a decision point has been exercised during testing. A good code coverage tool will not only give you the percentage of the code that is executed, but also allow you to see which lines of code, path, and conditions were and were not executed during testing.
Once the code has been checked in, all unit tests should be run again as part of the team’s continuous integration process to ensure the merging of code into a remote branch does not cause the tests to fail. Failing unit tests should fail the build and notify the desired individuals allowing for a quick turnaround in fixing any issues.
Best Practices
Execution should be consistent
Multiple runs of the same test should consistently return the same result. Without a known result, there is no guarantee the test will pass every time it runs. If it does fail, it can take some time to figure out why, because the unit test itself does not clearly document the functionality. If the unit being tested does not allow for a consistent result to be returned, then the code should be refactored until it can.
Make unit tests atomic
Each test created should be independent of other tests. This ensures tests can be run successfully in any order and there are no cascading failures of later tests when a particular test fails. Writing atomic tests simplifies maintenance and diagnosis of failures, reducing the amount of time a developer needs to spend on such tasks. In cases where a unit has an external dependency (another method, database call, etc.), mock objects can be used to remove the dependencies so the unit is isolated. Mock objects use the same implementation as the external objects, but they use predefined behavior for the tests using it.
Name unit tests descriptively
A developer should be able to quickly identify the expected behavior of the code just by looking at the unit tests. Additionally, when tests fail, a developer should know exactly which scenarios did not meet the expected behavior. This is accomplished in two ways. First, the name of the test should include the name of the function being tested, the scenario being tested, and the expected result. Second, developers should use informative assertion messages that include information about the behavior being tested, input values, and why the test failed. A few examples of unit test names can be found below in the Example section.
Give each test a single responsibility
A unit may behave in multiple ways, depending on its input. One test should be created to verify each behavior of a unit. Unit tests should not contain loops or conditional logic.
Structure tests properly
Unit tests should be written following the Arrange, Act, Assert pattern. Arrange initializes all needed objects and defines the variables for the data that will be passed into the unit under test, as well as any return values. Act invokes the unit with the appropriate test parameters. Assert validates that the return value for the unit under test and any other state changes are correct and the test passed.
Execute quickly
Unit tests allow developers to get quick, repeatable feedback to ensure that their code continues to behave as intended while they make changes. Developers are more likely to skip running unit tests locally if they take too long to run. Individual unit tests should run in milliseconds, with an entire suite taking no longer than a couple of minutes. One way to decrease the execution time of test suites is to split them into smaller suites separated by business model. It is equally important that the tests execute quickly during continuous integration. All unit tests should be executed after every check-in, providing developers with quick feedback and regression testing multiple times a day. The quicker the tests run, the quicker developers will know if the check-in caused any regression.
Anti-patterns
Testing only the happy path
It is common for developers to test only the scenarios they know will pass. Testing just the happy path leads to bugs being discovered later on in the process — as late as in production. Finding issues this late is much more costly to the project. When creating unit tests, developers must think about the edge cases and unintended input, such as null and incorrect values.
Writing only easy tests
This usually occurs when the developer is inexperienced and the code is difficult to test. Tests may not cover the complete functionality of the unit, leading to a result similar to happy-path testing, with bugs discovered later on in the process. If you find this occurring, it may be a red flag that your code needs to be refactored to simplify what each unit’s function is responsible for.
Example
Below is a simple example of writing tests and refactoring for a small unit of code. The same principles apply for much larger codebases or refactors.
A developer is tasked with creating a simple method that will concatenate two strings and return the result.
The Code
Below is the unit of code created. The expected behavior of the unit is to concatenate value2 to value1 and return the result.
protected String concatenate(String value1, String value2) {
value1 = value1 == null ? "" : value1;
value2 = value2 == null ? "" : value2;
return value1 + value2;
}
The First Test
The first test should test the expected behavior of the unit that we know should pass (the happy path).
@Test
public void test_concatenate_john_smith_should_return_johnsmith() {
String expected_result = “JohnSmith”;
String actual_result = concatenate(“John”,”Smith”);
assertEquals(expected_result, actual_result);
}
The test name above describes the:
- method being tested
- inputs being passed to the method
- expected result
From the name alone a team member can deduce
- what functionality the test is actually testing
- the expected behavior of this unit of code
Additional Tests
Now test some unexpected values and edge cases.
@Test
public void test_concatenate_null_smith_should_return_smith()
@Test
public void test_concatenate_john_emptystring_should_return_john()
@Test
public void test_concatenate_john_smith!_should_return_johnsmith!()
The refactor
After a few months, the code is refactored to improve its performance.
protected String concatenate(String value1, String value2) {
value1 = value1 == null ? "" : value1;
value2 = value2 == null ? "" : value2;
return StringBuilder(value1).append(value2).toString();
}
For this particular change, no new tests need to be written to test additional functionality. After refactoring the code, run all existing unit tests for the code base to ensure everything is still working as expected. If all tests pass, you can be confident that the change has not affected the behavior of the code. If a test fails, you may need to refactor the test, or there may be something wrong with the updates.
In Conclusion
When writing unit tests, start by testing how the unit of code is expected to behave (the “happy path”). Next, create additional unit tests to handle the edge cases of the code (unexpected values, thrown exceptions, etc.).
Do not allow broken tests to simply be turned off, ignored or deleted. A failing test is feedback that needs attention. It might be that a test is no longer needed because the unit of code it was testing was removed. Maybe the test was poorly written and needs updating. However, it could mean recent changes broke a well written unit test and needs to be revisited. All unit tests should be reviewed as part of any code review process to help ensure poorly written tests are not committed. Reviewing tests will help reduce the time it takes to investigate failing tests in the future.
In conclusion, well written and managed unit tests provide software development teams:
- Validation that the code works as the developer intended
- Increased code confidence
- Support for fearless refactoring
- Dynamic documentation
- Improved software design