Like unit testing but dislike mocks and stubs? Want to keep your business logic separate from your side effects? In this blog post, I'll demonstrate a novel approach to testing which relies not on the Dependency Injection pattern, but an algebraic data structure and some simple functions.
I was recently working on a system for a real estate company which merged a PDF-template with some data related a house that a real estate agent was trying to sell, and then rendered the output to the screen.
Each template contained a bunch of labeled boxes ("elements") - each associated
with a symbol which indicated the nature of their content (e.g.
house_1.photo_1
or agent_1.headshot_1
).
The software we wrote was responsible for ensuring that each element rendered with a user-provided image and that it was labeled appropriately.
An array of potential element labels was built up by applying the following set of rules:
- If the element had metadata that indicated that it was uneditable by the user, don't label the box
- If the element had a symbol which mapped directly to a hard-coded label, use it
- Otherwise, we'd resolve the box's symbol to a label via the i18n system
Our first attempt at building our label resolver took on a form familiar to most object-oriented programmers:
///////////////////////////////////////////////////////////////////////////////
// Shared Code
//////////////
interface PDF {
// resolve an image URL to an image and render it into the PDF with the
// provided label
render(element: TemplateElement, label: string, imgUrl: string):void
}
interface TemplateElement {
symbol: string;
isEditable: boolean;
}
interface I18nService {
// look up a translated string by locale + key
lookup(loc: Locale, key: string): string;
}
interface ImageUrlResolver {
// resolve the URL of some user-provided image for the element
resolve(userId: string, el: TemplateElement): string;
}
enum Locale {
EN_US,
EN_CA,
FR_CA,
// etc.
}
///////////////////////////////////////////////////////////////////////////////
// Example 1A: Dependency Injection
//////////////////////////////////
interface LabelResolver {
resolve(loc: Locale, el: TemplateElement): Array<string>;
}
class TemplateElementRenderer {
public labelResolver: LabelResolver;
public imageResolver: ImageUrlResolver;
constructor(i18nSvc: I18nService, imgResolver: ImageUrlResolver) {
this.labelResolver = new LabelResolver(i18nSvc);
this.imageResolver = imgResolver;
}
public renderElement(pdf: IPDF, loc: Locale, userId: string, el: TemplateElement): void {
pdf.render(
el,
this.labelResolver.resolve(loc, el),
this.imageResolver.resolve(userId, el))
}
}
class LabelResolverImpl implements LabelResolver {
static HARD_CODED_MAPPINGS: {[key: string]: string} = {
"house_1.photo_1": "Front of House",
"agent_1.headshot_1": "Agent 1 Headshot",
// etc.
};
public i18Service: I18nService;
constructor(i18nSvc: I18nService) {
this.i18Service = i18nSvc;
}
public resolve(loc: Locale, el: TemplateElement): string {
if (!el..isEditable) {
return "";
}
if (LabelResolver.HARD_CODED_MAPPINGS[el..symbol]) {
return LabelResolver.HARD_CODED_MAPPINGS[el..symbol];
}
const i18nLookupResult = this.i18Service.lookup(loc, el..symbol);
if (i18nLookupResult) {
return i18nLookupResult;
}
return "";
}
}
We instantiate a TemplateElementRenderer
with some collaborators that allow us
to interact with the outside world: I18nService
and ImageUrlResolver
and
then we pass the I18nService
to constructor of the nested LabelResolver
,
which we cache. Then, later, some program calls
TemplateElementRenderer#renderElement
, which delegates to its resolvers and
affects a change to the PDF.
Now, for the tests:
///////////////////////////////////////////////////////////////////////////////
// Example 1B: Testing w/TS-Mockito
//////////////////////////////////
describe("label resolution", function() {
it("should delegate to the i18n service", function() {
const i18nService: I18nService = mock(TheRealI18nService);
const resolver: LabelResolver = new LabelResolverImpl(instance(i18nService));
when(i18nService.lookup(Locale.EN_CA, "house_1.door_4"))
.thenReturn("Fourth House Door");
const el: TemplateElement = {
getSymbol: function() {
return "house_1.door_4"
},
isEditable: function() {
return true
}
};
const actual = resolver.resolve(Locale.EN_CA, el);
const expected = ["Fourth House Door"];
expect(actual).to.equal(expected);
});
});
Before testing the logic of our LabelResolver#resolve
method, we set up some
I18nService
state using ts-mockito stubs. We call our resolve
method
which delegates to some stubs, ultimately resulting in a return value that we expect.
The original implementation left a few things to be desired:
- We had business logic colocated with side effects (interactions with the i18n system)
- The
I18nService
reference needed to be plumbed through several classes - Our unit test was fairly high ceremony (i.e. required a bit of setup)
What I really wanted was for my LabelResolver#resolve
method to return a
different type depending on the nature of the values that it resolved, and the
enclosing class (TemplateElementRenderer
) will be responsible for interpreting
each value as it sees fit. To achieve this, I used something called a
discriminated union, or as it's also called in the TypeScript documentation,
an algebraic data type (ADT).
So what's an ADT - and how do we create them? According to the TypeScript docs, the programmer needs three things in order to create an ADT:
- Types that have a common, singleton type property — the discriminant.
- A type alias that takes the union of those types — the union.
- Type guards on the common property.
Following those guidelines, we create an ADT to be returned by functions which can either succeed or fail:
///////////////////////////////////////////////////////////////////////////////
// Example 2: TypeScript ADTs
/////////////////////////////
interface Failure<E> {
kind: "failure";
reason: E;
}
interface Success<T> {
kind: "success";
value: T;
}
type Result<T, E> = Failure<E> | Success<T>;
In the above example, the Failure
and Success
interfaces both have a common,
singleton type property - kind
. These interfaces are unioned together to form
our ADT, Result
. For any function accepting a Result
to be well typed it
must first examine the value of the Result
's kind
before making use of
Failure
or Success
-specific properties.
Some examples:
// fails with a TS2339 compiler error
function foo(x: Result<String, Error>): void {
console.log(x.value);
}
// type checks because of the "type guard on the common property"
function bar(r: Result<String, Error>): void {
switch (r.kind) {
case "success": return console.log(r.value);
case "failure": return console.log(r.reason);
}
}
// fails with a TS2322 error: Object literal may only specify known properties,
// and '"value"' does not exist in type 'Failure<String>'
const bad1: Result<String, String> = {
"kind": "failure",
"value": "wat",
"reason": "foo"
};
Using a technique similar to the one demonstrated in Rúnar Bjarnason's excellent
blog post Structural Pattern Matching in Java, we can hide the type guard
in a polymorphic matching-function, like result
(below):
// hide the type guard
function result<T, U, E>(
r: Result<T, E>,
onSuccess: (f1: Success<T>) => U,
onFailure: (f2: Failure<E>) => U
): U {
switch (r.kind) {
case "success": return onSuccess(r);
case "failure": return onFailure(r);
}
}
// example usage
console.log(result(
doSomethingThatCouldFail(),
function(s) {
return "success: " + s.value.toString();
}, function(e) {
return "failure!"
}));
Also of note is that TypeScript will prevent us from adding new types to our
union without expanding our switch
statement.
We create three new types to represent the nature of the strings that could be
returned from our resolve
method and union them together to create an ADT. Now,
our resolve
method can return a pure data structure and its caller can sort
out how to interpret them into calls into the i18n service:
///////////////////////////////////////////////////////////////////////////////
// Example 3A: Using an ADT in Our App
//////////////////////////////////////
// no need to go to the i18n system
interface ResolvedLabel {
kind: "resolved";
value: string;
}
// look up a string in the i18n system
interface UnresolvedLabel {
kind: "unresolved";
element: TemplateElement;
}
// we shouldn't label this element
interface ResolvedNoLabel {
kind: "nolabel";
}
type LabelResolutionResult = ResolvedLabel | ResolvedNoLabel | UnresolvedLabel;
function labelResolutionResult(
r: LabelResolutionResult,
onResolvedLabel: (f1: ResolvedLabel) => T,
onResolvedNoLabel: (f2: ResolvedNoLabel) => T,
onUnresolvedlabel: (f3: UnresolvedLabel) => T
): T {
switch (r.kind) {
case "resolved": return onResolvedLabel(r);
case "nolabel": return onResolvedNoLabel(r);
case "unresolved": return onUnresolvedlabel(r);
}
}
class TemplateElementRenderer {
public i18nService: I18nService;
public imageResolver: ImageUrlResolver;
constructor(i18nSvc: I18nService, imgResolver: ImageUrlResolver) {
this.i18nService = i18nSvc;
this.imageResolver = imgResolver;
}
public renderElement(pdf: PDF, loc: Locale, userId: string, el: TemplateElement): void {
// interact with the i18n service if need be
const label: string = labelResolutionResult(
LabelResolver2.resolve(el),
(resolved) => resolved.value,
() => "",
(unresolved) => this.i18nService.lookup(loc, unresolved.element..symbol)
);
pdf.render(el, label, this.imageResolver.resolve(userId, el));
}
}
class LabelResolver {
static HARD_CODED_MAPPINGS: {[key: string]: string} = {
"house_1.photo_1": "Front of House",
"agent_1.headshot_1": "Agent 1 Headshot",
// etc.
};
public static resolve(el: TemplateElement): LabelResolutionResult {
if (!el..isEditable) {
return { "kind": "nolabel" };
}
if (LabelResolver.HARD_CODED_MAPPINGS[el..symbol]) {
return {
"kind": "resolved",
"value": LabelResolver.HARD_CODED_MAPPINGS[el..symbol]
};
}
return {
"kind": "unresolved",
"element": el
};
}
}
And finally, our tests can be written in a much more-straightforward manner:
///////////////////////////////////////////////////////////////////////////////
// Example 3B: Testing with an ADT
//////////////////////////////////
describe("label resolution", function() {
it("should return a match for a hard-coded mapping, if exists", function() {
const el: TemplateElement = {
"symbol": "house_1.photo_1",
"isEditable": true
};
const expected = "Front of House";
const result = LabelResolver.resolve(Locale.EN_US, el);
const actual = labelResolutionResult(
result,
(resolved) => resolved.value,
() => throw new Error("unexpected"),
(unresolved) => throw new Error("unexpected"))
expect(actual).to.equal(expected);
});
it("should return an unresolved value", function() {
const el: TemplateElement = {
"symbol": "house_1.photo_1",
"isEditable": true
};
const expected = "foo";
const result = LabelResolver.resolve(Locale.EN_US, el);
const actual = labelResolutionResult(
result,
(resolved) => throw new Error("unexpected"),
() => throw new Error("unexpected"),
(unresolved) => "foo")
expect(actual).to.equal(expected);
});
});
Given that our new resolve
method doesn't need a reference to the i18n
service, our tests can avoid stubs and instead operate on pure data.
I hope I've amused you with an ADT-based approach to inversion of control. I think that this approach aids us in:
- Separating pure business logic from impure, side-effectful code
- Decreasing the number of classes that need to know about the collaborators of nested classes
- Decreasing the amount of test setup code that we'd otherwise need to write
Like it? Hate it? Leave your thoughts in the comments section, below!