Testing With SIFERS

Moshe Kolodny
5 min readDec 17, 2019

--

This article outlines a more efficient way to write unit tests which contain lots of instrumentation and setup code. The framework (React/Angular) and the test framework (Jest/Jasmine) aren’t important since these pain points exist in all types of testing environment, however, this article tries to keep the examples somewhat framework agnostic to help highlight the problem and solution at hand.

Setup code in component tests have a tendency to grow in lines of code and complexity, and ultimately up being hard to grok in later iterations. For example let’s focus on a simple unit test:

Simple enough. Later on, we need to support some backend service calls on this component, still manageable without too much worry

We can also add nested tests to spec out a logical grouping of tests:

Now suppose a new feature is added where the undo flag can be disabled for some particular use case and the component ends up looking something like this:

function MyComponent() {
// Memoize this value so we don't need to keep figuring
//out if we should enable the readonly flag.
const isReadonly = useMemo(computeIsReadonlyFlagExpensive, []);
return (
<div>
...
{!isReadonly && <input className="undo" />}
...
</div>
);
}

Adding a spec for this in the usual place will require us to pull out the bootstrapping to each individual test:

We needed to have the return value of `computeIsReadonlyFlagExpensive` be false for the last test and that required all previous tests to bootstrap the component in the `it` block.

The above example may be contrived, however it’s pretty common to need to extract setup code due to some custom logic that needs to happen for a new test. Whenever that happens, the tests become harder to follow and to extend in the future.

Introducing: Simple Injectable Functions Explicitly Returning State (SIFERS)

Simple Injectable Functions Explicitly Returning State (SIFERS) are a way to capture what the tests should do when setting up the testing environment as well as returning a mutable clean state. A SIFERS is just a function that accepts some overridable/injectable parameters and returns some form of state. In the first example above (and probably in most cases) only one SIFERS is needed for the suite:

Notice that the SIFERS returns a state that is guaranteed to not be affected by other tests in the suite. This allows returning spy objects as part of the state and letting the tests alter the return values mid test without having to worry about any leakage. SIFERS can also be nested if there is some logic that needs to happen shared across a sub suite. Taking the example above, we can have a nested SIFERS to setup any functionality needed to save a widget in the nested suite while forwarding any state to the parent SIFERS:

Pros and Cons

There are a couple of obvious advantages to adopting this approach:

  • Extreme ease to add complex setup and shared code to a group of tests
  • Impossible for tests to leak to other tests. There could have been a test to modify the registerWidget method to throw an error in the middle of the test without worrying about other tests picking up that change
  • No need to ever rewrite an entire suite to accommodate a tricky test
  • Removes the need for beforeEach (the complex suite above didn’t have any) and forces explicit invocation which makes them simple to reason about. This also makes debugging simpler since you can step through the setup code without needing to set breakpoints all over the place
  • No need to declare a variable in the describe block in order to set the value the beforeEach block. This removes the possibility of folks doing something like
    let serviceSpy: jasmine.SpyObj and in the beforeEach doing
    serviceSpy = jasmine.createSpyObj<Service>(‘service’, [‘method’]); in that example the spy’s type is lost due to the way it was declared in the let
  • Type safety goodness. By providing default values, the type system will assume that type for injectable value. This can potentially save someone from trying to set an incorrect return type for a spy. In the example above, setup has type:
setup({ isReadonlyFlagSet, }?: {
isReadonlyFlagSet?: boolean | undefined;
}): Promise<{
component: HTMLElement;
widgetServiceSpyObject: any;
}>

Passing a number to isReadonlyFlagSet would throw a compile time error

  • Encourages explicitly providing defaults for spy return values or any overridable value
  • It’s just functions!

There are surprisingly few downsides to SIFERS

  • New pattern that users need to familiarize themselves with
  • Need to call a function at the beginning of each test (which can also be viewed as a positive attribute)
  • Not idiomatic (yet 🙂)

Using SIFERS in existing codebases

SIFERS are not an all or nothing construct. For existing codebases there is no need to change old code to use SIFERS. For test suites (i.e. describe blocks) added to existing tests, there are a couple of approaches we can take

  • Keep the testing style consistent throughout the file and not use any SIFERS
  • Use SIFERS in conjunction with the beforeEach style
  • Only use SIFERS within the new test suite

SIFERS Patterns

Here is a collection of some common patterns employed when using SIFERS

Union Typed Arguments

Suppose you have a spied function that returns strings or numbers, it’s not possible to supply a default value to the injectable value without it narrowing down to the type you use:

function setup({
value = 123,
} = {}){
spy.and.returnValue(value);
}
// Error: Arg of 'string' is not assignable to type 'number'
setup({value: 'test'})

The solution to this is to cast the value to the union type:

function setup({
value = 123 as number | string,
} = {}){
spy.and.returnValue(value);
}
setup({value: 'test'}); // OK

Empty Default Value

The solution above can also be used to allow an injectable value to not be set at all by default:

function setup({
notNeeded = undefined as undefined | number,
} = {}){
if (notNeeded !== undefined) {
optionalSpy.and.returnValue(notNeeded);
}
}

Require Injected Argument

Some SIFERS may require an argument be provided and not use the default value. This can be accomplished by throwing an error in the default value returning never and casting the result:

function requiredParameter(): never {
throw new Error('Missing parameter');
}
function setup({
thisCanBeDefaulted = 123,
thisNeedsToBeSet = requiredParameter() as number,
} = {}){
thisNeedsToBeSet; // is a number
}

Conclusion

SIFERS is a powerful mechanism for writing tests, ensuring that business logic for tests don’t add unneeded complexity, allows easy extensibility, and makes following the flow of tests simple and readable.

--

--