Skip to content

Instantly share code, notes, and snippets.

@ashevin
Last active December 12, 2019 13:23
Show Gist options
  • Save ashevin/29e547dadab77a45007ea52ca08f3ef8 to your computer and use it in GitHub Desktop.
Save ashevin/29e547dadab77a45007ea52ca08f3ef8 to your computer and use it in GitHub Desktop.
Unit Testing

Unit Testing

The Unit

While it is generally understood that unit testing is the activity of running tests to ensure that a unit is upholding its contract, the concept of a unit is often left undefined. In short, a unit is a subset of code which can be compiled on its own. For example, a framework or library is a unit. However, units may be comprised of other units. Internal types may form their own units, which are depended on by the public interface of the library. Any type with functionality (methods) may form its own unit.

The Tests

One of the fundamental differences between unit testing and other forms of software testing is the specificity of the tests. Each test covers a single entry point into the public interface, and does not endeavor to test how pieces interact. Any such interactions are treated as implementation details. The unit test ensures that the contract of the entry point is met. The how is unimportant.

Public Inter-what?

Unit tests are uninterested in implementation details. Instead, they focus on the functionality that the unit offers to the public. The public may be other units within the library, or users of the library itself. Any code which is not part of the unit is part of the public. The functions (including constructors) which are available outside the unit form its public interface.

What's a Contract?

In terms of software, a contract is the specification which defines the acceptable inputs and outputs of a function. Included are boundary conditions for inputs, and how invalid inputs are handled.

It is unfeasible for unit tests to cover every conceivable value of every single parameter of a public interface. For this reason, unit tests usually focus on testing boundary conditions and handling of invalid input.

What to Cover

Unit tests should not test the compiler, nor should they test other units, including external and system libraries. Tests should instead focus on the unique functionality provided by the unit. Business logic. Data transformations. Error handling.

Programmer Errors

A contract may specify that certain inputs are not handled by the function, and that specifying such an input is a programming error. Generally, such conditions are tested via assertions, and will trigger a program abort. Common examples are integer division by zero and out-of-bounds accesses of arrays.

Do not attempt to test such conditions. (XCTest provides no facility for testing assertions, but the principle should be generalized to other testing frameworks.)

100% Coverage

Complete coverage is an ideal; it is not a goal. The goal is to validate that the unit upholds its contract. Units may contain code that is not a part of its contract. Sometimes, there will be code to satisfy the compiler, but which can only be reached via a programming error, or by changing the code of the unit itself. One such example is default handling for switch cases. Proper input filtering may preclude an unhandled case, but the compiler is unable to reason as such.

Code coverage is a tool. It can be used to find branches which are not being tested. While implementation details are not the focus of unit tests, code branches often exist to handle specific combinations or values of inputs. It is proper to have tests which probe the handling of such inputs.

Testable Code

How to organize code for good testability has been the subject of many (large) books. I will provide a few guidelines.

  • Use pure functions. Pure functions are those in which the output depends only on the input, and not on any external factors. Pure functions are trivial to test.
  • Write long transformations or processing pipelines using separate functions for each step. This will allow testing each step separately. This is especially valuable when the data being processed are instance variables of a class or struct. By splitting the steps into pure functions, those steps can be easily tested.
  • Follow development principles for separation of concerns. Business logic is both easier and more important to test. Keeping it separate from your view code simplifies testing.
  • Split networking code into functions for receiving data and functions for parsing data. The latter can be easily tested using mock data, and the former will often not require testing, as it will be a thin wrapper over system or third-party libraries.
  • In a similar vein, split functions that build (network) requests from the code which submits them.

Asynchronous Code

Asynchronous code with race conditions will lead to intermittent test failures. If you experience such, debug the race condition, and not the specific method which behaved unexpectedly. While these may be one-and-the-same, the point is to take a holistic approach, and not to assume that, for example, the computer forgot how to add two numbers or insert a value into an array.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment