Skip to content

Commit

Permalink
feat(testing): add mocking utilities (#2048)
Browse files Browse the repository at this point in the history
  • Loading branch information
KyleJune authored Mar 29, 2022
1 parent d697527 commit 7213d52
Show file tree
Hide file tree
Showing 10 changed files with 2,941 additions and 0 deletions.
211 changes: 211 additions & 0 deletions testing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,214 @@ with the `BenchmarkRunOptions.silent` flag.
Clears all registered benchmarks, so calling `runBenchmarks()` after it wont run
them. Filtering can be applied by setting `BenchmarkRunOptions.only` and/or
`BenchmarkRunOptions.skip` to regular expressions matching benchmark names.

## Mocking

Test spies are function stand-ins that are used to assert if a function's
internal behavior matches expectations. Test spies on methods keep the original
behavior but allow you to test how the method is called and what it returns.
Test stubs are an extension of test spies that also replaces the original
methods behavior.

### Spying

Say we have two functions, `square` and `multiply`, if we want to assert that
the `multiply` function is called during execution of the `square` function we
need a way to spy on the `multiple` function. There are a few ways to achieve
this with Spies, one is to have the `square` function take the `multiply`
multiply as a parameter.

```ts
// https://deno.land/std@$STD_VERSION/testing/mock_examples/parameter_injection.ts
export function multiply(a: number, b: number): number {
return a * b;
}

export function square(
multiplyFn: (a: number, b: number) => number,
value: number,
): number {
return multiplyFn(value, value);
}
```

This way, we can call `square(multiply, value)` in the application code or wrap
a spy function around the `multiply` function and call
`square(multiplySpy, value)` in the testing code.

```ts
// https://deno.land/std@$STD_VERSION/testing/mock_examples/parameter_injection_test.ts
import {
assertSpyCall,
assertSpyCalls,
spy,
} from "https://deno.land/std@$STD_VERSION/testing/mock.ts";
import { assertEquals } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts";
import {
multiply,
square,
} from "https://deno.land/std@$STD_VERSION/testing/mock_examples/parameter_injection.ts";

Deno.test("square calls multiply and returns results", () => {
const multiplySpy = spy(multiply);

assertEquals(square(multiplySpy, 5), 25);

// asserts that multiplySpy was called at least once and details about the first call.
assertSpyCall(multiplySpy, 0, {
args: [5, 5],
returned: 25,
});

// asserts that multiplySpy was only called once.
assertSpyCalls(multiplySpy, 1);
});
```

If you prefer not adding additional parameters for testing purposes only, you
can use spy to wrap a method on an object instead. In the following example, the
exported `_internals` object has the `multiply` function we want to call as a
method and the `square` function calls `_internals.multiply` instead of
`multiply`.

```ts
// https://deno.land/std@$STD_VERSION/testing/mock_examples/internals_injection.ts
export function multiply(a: number, b: number): number {
return a * b;
}

export function square(value: number): number {
return _internals.multiply(value, value);
}

export const _internals = { multiply };
```

This way, we can call `square(value)` in both the application code and testing
code. Then spy on the `multiply` method on the `_internals` object in the
testing code to be able to spy on how the `square` function calls the `multiply`
function.

```ts
// https://deno.land/std@$STD_VERSION/testing/mock_examples/internals_injection_test.ts
import {
assertSpyCall,
assertSpyCalls,
spy,
} from "https://deno.land/std@$STD_VERSION/testing/mock.ts";
import { assertEquals } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts";
import {
_internals,
square,
} from "https://deno.land/std@$STD_VERSION/testing/mock_examples/internals_injection.ts";

Deno.test("square calls multiply and returns results", () => {
const multiplySpy = spy(_internals, "multiply");

try {
assertEquals(square(5), 25);
} finally {
// unwraps the multiply method on the _internals object
multiplySpy.restore();
}

// asserts that multiplySpy was called at least once and details about the first call.
assertSpyCall(multiplySpy, 0, {
args: [5, 5],
returned: 25,
});

// asserts that multiplySpy was only called once.
assertSpyCalls(multiplySpy, 1);
});
```

One difference you may have noticed between these two examples is that in the
second we call the `restore` method on `multiplySpy` function. That is needed to
remove the spy wrapper from the `_internals` object's `multiply` method. The
`restore` method is called in a finally block to ensure that it is restored
whether or not the assertion in the try block is successful. The `restore`
method didn't need to be called in the first example because the `multiply`
function was not modified in any way like the `_internals` object was in the
second example.

### Stubbing

Say we have two functions, `randomMultiple` and `randomInt`, if we want to
assert that `randomInt` is called during execution of `randomMultiple` we need a
way to spy on the `randomInt` function. That could be done with either either of
the spying techniques previously mentioned. To be able to verify that the
`randomMultiple` function returns the value we expect it to for what `randomInt`
returns, the easiest way would be to replace the `randomInt` function's behavior
with more predictable behavior.

You could use the first spying technique to do that but that would require
adding a `randomInt` parameter to the `randomMultiple` function.

You could also use the second spying technique to do that, but your assertions
would not be as predictable due to the `randomInt` function returning random
values.

Say we want to verify it returns correct values for both negative and positive
random integers. We could easily do that with stubbing. The below example is
similar to the second spying technique example but instead of passing the call
through to the original `randomInt` function, we are going to replace
`randomInt` with a function that returns pre-defined values.

```ts
// https://deno.land/std@$STD_VERSION/testing/mock_examples/random.ts
export function randomInt(lowerBound: number, upperBound: number): number {
return lowerBound + Math.floor(Math.random() * (upperBound - lowerBound));
}

export function randomMultiple(value: number): number {
return value * _internals.randomInt(-10, 10);
}

export const _internals = { randomInt };
```

The mock module includes some helper functions to make creating common stubs
easy. The `returnsNext` function takes an array of values we want it to return
on consecutive calls.

```ts
// https://deno.land/std@$STD_VERSION/testing/mock_examples/random_test.ts
import {
assertSpyCall,
assertSpyCalls,
returnsNext,
stub,
} from "https://deno.land/std@$STD_VERSION/testing/mock.ts";
import { assertEquals } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts";
import {
_internals,
randomMultiple,
} from "https://deno.land/std@$STD_VERSION/testing/mock_examples/random.ts";

Deno.test("randomMultiple uses randomInt to generate random multiples between -10 and 10 times the value", () => {
const randomIntStub = stub(_internals, "randomInt", returnsNext([-3, 3]));

try {
assertEquals(randomMultiple(5), -15);
assertEquals(randomMultiple(5), 15);
} finally {
// unwraps the randomInt method on the _internals object
randomIntStub.restore();
}

// asserts that randomIntStub was called at least once and details about the first call.
assertSpyCall(randomIntStub, 0, {
args: [-10, 10],
returned: -3,
});
// asserts that randomIntStub was called at least twice and details about the second call.
assertSpyCall(randomIntStub, 1, {
args: [-10, 10],
returned: 3,
});

// asserts that randomIntStub was only called twice.
assertSpyCalls(randomIntStub, 2);
});
```
18 changes: 18 additions & 0 deletions testing/_test_utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export class Point {
constructor(public x: number, public y: number) {}
// deno-lint-ignore no-explicit-any
action(...args: any[]): any {
return args[0];
}
toString(): string {
return [this.x, this.y].join(", ");
}
*[Symbol.iterator](): IterableIterator<number> {
yield this.x;
yield this.y;
}
}

export function stringifyPoint(point: Point) {
return point.toString();
}
Loading

0 comments on commit 7213d52

Please sign in to comment.