Skip to content

Commit

Permalink
Make test context optionally type aware for TypeScript (#1298)
Browse files Browse the repository at this point in the history
  • Loading branch information
mmkal authored and sindresorhus committed Mar 14, 2017
1 parent 827cc72 commit 50ad213
Show file tree
Hide file tree
Showing 4 changed files with 101 additions and 48 deletions.
31 changes: 30 additions & 1 deletion docs/recipes/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ AVA comes bundled with a TypeScript definition file. This allows developers to l

## Setup

First install [TypeScript](https://github.com/Microsoft/TypeScript).
First install [TypeScript](https://github.com/Microsoft/TypeScript) (if you already have it installed, make sure you use version 2.1 or greater).

```
$ npm install --save-dev typescript
Expand Down Expand Up @@ -50,6 +50,35 @@ test(async (t) => {
});
```

## Working with [`context`](https://github.com/avajs/ava#test-context)

By default, the type of `t.context` will be [`any`](https://www.typescriptlang.org/docs/handbook/basic-types.html#any). AVA exposes an interface `RegisterContextual<T>` which you can use to apply your own type to `t.context`. This can help you catch errors at compile-time:

```ts
import * as ava from 'ava';

function contextualize<T>(getContext: () => T): ava.RegisterContextual<T> {
ava.test.beforeEach(t => {
Object.assign(t.context, getContext());
});

return ava.test;
}

const test = contextualize(() => ({ foo: 'bar' }));

test.beforeEach(t => {
t.context.foo = 123; // error: Type '123' is not assignable to type 'string'
});

test.after.always.failing.cb.serial('very long chains are properly typed', t => {
t.context.fooo = 'a value'; // error: Property 'fooo' does not exist on type '{ foo: string }'
});

test('an actual test', t => {
t.deepEqual(t.context.foo.map(c => c), ['b', 'a', 'r']); // error: Property 'map' does not exist on type 'string'
});
```

## Execute the tests

Expand Down
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,8 @@ Keep in mind that the `beforeEach` and `afterEach` hooks run just before and aft

Remember that AVA runs each test file in its own process. You may not have to clean up global state in a `after`-hook since that's only called right before the process exits.

#### Test context

The `beforeEach` & `afterEach` hooks can share context with the test:

```js
Expand Down
37 changes: 22 additions & 15 deletions types/base.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
export default test;

export type ErrorValidator
= (new (...args: any[]) => any)
| RegExp
Expand All @@ -9,11 +7,16 @@ export type ErrorValidator
export interface Observable {
subscribe(observer: (value: {}) => void): void;
}

export type Test = (t: TestContext) => PromiseLike<void> | Iterator<any> | Observable | void;
export type ContextualTest = (t: ContextualTestContext) => PromiseLike<void> | Iterator<any> | Observable | void;
export type GenericTest<T> = (t: GenericTestContext<T>) => PromiseLike<void> | Iterator<any> | Observable | void;
export type CallbackTest = (t: CallbackTestContext) => void;
export type ContextualCallbackTest = (t: ContextualCallbackTestContext) => void;
export type GenericCallbackTest<T> = (t: GenericCallbackTestContext<T>) => void;

export interface Context<T> { context: T }
export type AnyContext = Context<any>;

export type ContextualTest = GenericTest<AnyContext>;
export type ContextualCallbackTest = GenericCallbackTest<AnyContext>;

export interface AssertContext {
/**
Expand Down Expand Up @@ -99,20 +102,24 @@ export interface CallbackTestContext extends TestContext {
*/
end(): void;
}
export interface ContextualTestContext extends TestContext {
context: any;
}
export interface ContextualCallbackTestContext extends CallbackTestContext {
context: any;
}

export type GenericTestContext<T> = TestContext & T;
export type GenericCallbackTestContext<T> = CallbackTestContext & T;

export interface Macro<T> {
(t: T, ...args: any[]): void;
title? (providedTitle: string, ...args: any[]): string;
}
export type Macros<T> = Macro<T> | Macro<T>[];

export function test(name: string, run: ContextualTest): void;
export function test(run: ContextualTest): void;
export function test(name: string, run: Macros<ContextualTestContext>, ...args: any[]): void;
export function test(run: Macros<ContextualTestContext>, ...args: any[]): void;
interface RegisterBase<T> {
(name: string, run: GenericTest<T>): void;
(run: GenericTest<T>): void;
(name: string, run: Macros<GenericTestContext<T>>, ...args: any[]): void;
(run: Macros<GenericTestContext<T>>, ...args: any[]): void;
}

export default test;
export const test: RegisterContextual<any>;
export interface RegisterContextual<T> extends Register<Context<T>> {
}
79 changes: 47 additions & 32 deletions types/make.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ const base = fs.readFileSync(path.join(__dirname, 'base.d.ts'), 'utf8');
// All suported function names
const allParts = Object.keys(runner._chainableMethods).filter(name => name !== 'test');

// The output consists of the base declarations, the actual 'test' function declarations,
// and the namespaced chainable methods.
const output = base + generatePrefixed([]);

fs.writeFileSync(path.join(__dirname, 'generated.d.ts'), output);

// Generates type definitions, for the specified prefix
Expand All @@ -43,10 +46,21 @@ function generatePrefixed(prefix) {

// If `parts` is not sorted, we alias it to the sorted chain
if (!isArraySorted(parts)) {
const chain = parts.sort().join('.');

if (exists(parts)) {
output += `\texport const ${part}: typeof test.${chain};\n`;
parts.sort();

let chain;
if (hasChildren(parts)) {
chain = parts.join('_') + '<T>';
} else {
// this is a single function, not a namespace, so there's no type associated
// and we need to dereference it as a property type
const last = parts.pop();
const joined = parts.join('_');
chain = `${joined}<T>['${last}']`;
}

output += `\t${part}: Register_${chain};\n`;
}

continue;
Expand All @@ -56,14 +70,19 @@ function generatePrefixed(prefix) {
// `always` is a valid prefix, for instance of `always.after`,
// but not a valid function name.
if (verify(parts, false)) {
if (parts.indexOf('todo') !== -1) { // eslint-disable-line no-negated-condition
output += '\t' + writeFunction(part, 'name: string', 'void');
if (arrayHas(parts)('todo')) {
// 'todo' functions don't have a function argument, just a string
output += `\t${part}: (name: string) => void;\n`;
} else {
const type = testType(parts);
output += '\t' + writeFunction(part, `name: string, implementation: ${type}`);
output += '\t' + writeFunction(part, `implementation: ${type}`);
output += '\t' + writeFunction(part, `name: string, implementation: Macros<${type}Context>, ...args: any[]`);
output += '\t' + writeFunction(part, `implementation: Macros<${type}Context>, ...args: any[]`);
output += `\t${part}: RegisterBase<T>`;

if (hasChildren(parts)) {
// this chain can be continued, make the property an intersection type with the chain continuation
const joined = parts.join('_');
output += ` & Register_${joined}<T>`;
}

output += ';\n';
}
}

Expand All @@ -74,13 +93,14 @@ function generatePrefixed(prefix) {
return children;
}

const namespace = ['test'].concat(prefix).join('.');
const typeBody = `{\n${output}}\n${children}`;

return `export namespace ${namespace} {\n${output}}\n${children}`;
}

function writeFunction(name, args) {
return `export function ${name}(${args}): void;\n`;
if (prefix.length === 0) {
// no prefix, so this is the type for the default export
return `export interface Register<T> extends RegisterBase<T> ${typeBody}`;
}
const namespace = ['Register'].concat(prefix).join('_');
return `interface ${namespace}<T> ${typeBody}`;
}

// Checks whether a chain is a valid function name (when `asPrefix === false`)
Expand Down Expand Up @@ -126,6 +146,17 @@ function verify(parts, asPrefix) {
return true;
}

// Returns true if a chain can have any child properties
function hasChildren(parts) {
// concatenate the chain with each other part, and see if any concatenations are valid functions
const validChildren = allParts
.filter(newPart => parts.indexOf(newPart) === -1)
.map(newPart => parts.concat([newPart]))
.filter(longer => verify(longer, false));

return validChildren.length > 0;
}

// Checks whether a chain is a valid function name or a valid prefix with some member
function exists(parts) {
if (verify(parts, false)) {
Expand All @@ -147,19 +178,3 @@ function exists(parts) {

return false;
}

// Returns the type name of for the test implementation
function testType(parts) {
const has = arrayHas(parts);
let type = 'Test';

if (has('cb')) {
type = `Callback${type}`;
}

if (!has('before') && !has('after')) {
type = `Contextual${type}`;
}

return type;
}

0 comments on commit 50ad213

Please sign in to comment.