Skip to content

Commit 50ad213

Browse files
mmkalsindresorhus
authored andcommitted
Make test context optionally type aware for TypeScript (#1298)
1 parent 827cc72 commit 50ad213

File tree

4 files changed

+101
-48
lines changed

4 files changed

+101
-48
lines changed

docs/recipes/typescript.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ AVA comes bundled with a TypeScript definition file. This allows developers to l
66

77
## Setup
88

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

1111
```
1212
$ npm install --save-dev typescript
@@ -50,6 +50,35 @@ test(async (t) => {
5050
});
5151
```
5252

53+
## Working with [`context`](https://github.com/avajs/ava#test-context)
54+
55+
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:
56+
57+
```ts
58+
import * as ava from 'ava';
59+
60+
function contextualize<T>(getContext: () => T): ava.RegisterContextual<T> {
61+
ava.test.beforeEach(t => {
62+
Object.assign(t.context, getContext());
63+
});
64+
65+
return ava.test;
66+
}
67+
68+
const test = contextualize(() => ({ foo: 'bar' }));
69+
70+
test.beforeEach(t => {
71+
t.context.foo = 123; // error: Type '123' is not assignable to type 'string'
72+
});
73+
74+
test.after.always.failing.cb.serial('very long chains are properly typed', t => {
75+
t.context.fooo = 'a value'; // error: Property 'fooo' does not exist on type '{ foo: string }'
76+
});
77+
78+
test('an actual test', t => {
79+
t.deepEqual(t.context.foo.map(c => c), ['b', 'a', 'r']); // error: Property 'map' does not exist on type 'string'
80+
});
81+
```
5382

5483
## Execute the tests
5584

readme.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,8 @@ Keep in mind that the `beforeEach` and `afterEach` hooks run just before and aft
578578

579579
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.
580580

581+
#### Test context
582+
581583
The `beforeEach` & `afterEach` hooks can share context with the test:
582584

583585
```js

types/base.d.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
export default test;
2-
31
export type ErrorValidator
42
= (new (...args: any[]) => any)
53
| RegExp
@@ -9,11 +7,16 @@ export type ErrorValidator
97
export interface Observable {
108
subscribe(observer: (value: {}) => void): void;
119
}
12-
1310
export type Test = (t: TestContext) => PromiseLike<void> | Iterator<any> | Observable | void;
14-
export type ContextualTest = (t: ContextualTestContext) => PromiseLike<void> | Iterator<any> | Observable | void;
11+
export type GenericTest<T> = (t: GenericTestContext<T>) => PromiseLike<void> | Iterator<any> | Observable | void;
1512
export type CallbackTest = (t: CallbackTestContext) => void;
16-
export type ContextualCallbackTest = (t: ContextualCallbackTestContext) => void;
13+
export type GenericCallbackTest<T> = (t: GenericCallbackTestContext<T>) => void;
14+
15+
export interface Context<T> { context: T }
16+
export type AnyContext = Context<any>;
17+
18+
export type ContextualTest = GenericTest<AnyContext>;
19+
export type ContextualCallbackTest = GenericCallbackTest<AnyContext>;
1720

1821
export interface AssertContext {
1922
/**
@@ -99,20 +102,24 @@ export interface CallbackTestContext extends TestContext {
99102
*/
100103
end(): void;
101104
}
102-
export interface ContextualTestContext extends TestContext {
103-
context: any;
104-
}
105-
export interface ContextualCallbackTestContext extends CallbackTestContext {
106-
context: any;
107-
}
105+
106+
export type GenericTestContext<T> = TestContext & T;
107+
export type GenericCallbackTestContext<T> = CallbackTestContext & T;
108108

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

115-
export function test(name: string, run: ContextualTest): void;
116-
export function test(run: ContextualTest): void;
117-
export function test(name: string, run: Macros<ContextualTestContext>, ...args: any[]): void;
118-
export function test(run: Macros<ContextualTestContext>, ...args: any[]): void;
115+
interface RegisterBase<T> {
116+
(name: string, run: GenericTest<T>): void;
117+
(run: GenericTest<T>): void;
118+
(name: string, run: Macros<GenericTestContext<T>>, ...args: any[]): void;
119+
(run: Macros<GenericTestContext<T>>, ...args: any[]): void;
120+
}
121+
122+
export default test;
123+
export const test: RegisterContextual<any>;
124+
export interface RegisterContextual<T> extends Register<Context<T>> {
125+
}

types/make.js

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ const base = fs.readFileSync(path.join(__dirname, 'base.d.ts'), 'utf8');
2424
// All suported function names
2525
const allParts = Object.keys(runner._chainableMethods).filter(name => name !== 'test');
2626

27+
// The output consists of the base declarations, the actual 'test' function declarations,
28+
// and the namespaced chainable methods.
2729
const output = base + generatePrefixed([]);
30+
2831
fs.writeFileSync(path.join(__dirname, 'generated.d.ts'), output);
2932

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

4447
// If `parts` is not sorted, we alias it to the sorted chain
4548
if (!isArraySorted(parts)) {
46-
const chain = parts.sort().join('.');
47-
4849
if (exists(parts)) {
49-
output += `\texport const ${part}: typeof test.${chain};\n`;
50+
parts.sort();
51+
52+
let chain;
53+
if (hasChildren(parts)) {
54+
chain = parts.join('_') + '<T>';
55+
} else {
56+
// this is a single function, not a namespace, so there's no type associated
57+
// and we need to dereference it as a property type
58+
const last = parts.pop();
59+
const joined = parts.join('_');
60+
chain = `${joined}<T>['${last}']`;
61+
}
62+
63+
output += `\t${part}: Register_${chain};\n`;
5064
}
5165

5266
continue;
@@ -56,14 +70,19 @@ function generatePrefixed(prefix) {
5670
// `always` is a valid prefix, for instance of `always.after`,
5771
// but not a valid function name.
5872
if (verify(parts, false)) {
59-
if (parts.indexOf('todo') !== -1) { // eslint-disable-line no-negated-condition
60-
output += '\t' + writeFunction(part, 'name: string', 'void');
73+
if (arrayHas(parts)('todo')) {
74+
// 'todo' functions don't have a function argument, just a string
75+
output += `\t${part}: (name: string) => void;\n`;
6176
} else {
62-
const type = testType(parts);
63-
output += '\t' + writeFunction(part, `name: string, implementation: ${type}`);
64-
output += '\t' + writeFunction(part, `implementation: ${type}`);
65-
output += '\t' + writeFunction(part, `name: string, implementation: Macros<${type}Context>, ...args: any[]`);
66-
output += '\t' + writeFunction(part, `implementation: Macros<${type}Context>, ...args: any[]`);
77+
output += `\t${part}: RegisterBase<T>`;
78+
79+
if (hasChildren(parts)) {
80+
// this chain can be continued, make the property an intersection type with the chain continuation
81+
const joined = parts.join('_');
82+
output += ` & Register_${joined}<T>`;
83+
}
84+
85+
output += ';\n';
6786
}
6887
}
6988

@@ -74,13 +93,14 @@ function generatePrefixed(prefix) {
7493
return children;
7594
}
7695

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

79-
return `export namespace ${namespace} {\n${output}}\n${children}`;
80-
}
81-
82-
function writeFunction(name, args) {
83-
return `export function ${name}(${args}): void;\n`;
98+
if (prefix.length === 0) {
99+
// no prefix, so this is the type for the default export
100+
return `export interface Register<T> extends RegisterBase<T> ${typeBody}`;
101+
}
102+
const namespace = ['Register'].concat(prefix).join('_');
103+
return `interface ${namespace}<T> ${typeBody}`;
84104
}
85105

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

149+
// Returns true if a chain can have any child properties
150+
function hasChildren(parts) {
151+
// concatenate the chain with each other part, and see if any concatenations are valid functions
152+
const validChildren = allParts
153+
.filter(newPart => parts.indexOf(newPart) === -1)
154+
.map(newPart => parts.concat([newPart]))
155+
.filter(longer => verify(longer, false));
156+
157+
return validChildren.length > 0;
158+
}
159+
129160
// Checks whether a chain is a valid function name or a valid prefix with some member
130161
function exists(parts) {
131162
if (verify(parts, false)) {
@@ -147,19 +178,3 @@ function exists(parts) {
147178

148179
return false;
149180
}
150-
151-
// Returns the type name of for the test implementation
152-
function testType(parts) {
153-
const has = arrayHas(parts);
154-
let type = 'Test';
155-
156-
if (has('cb')) {
157-
type = `Callback${type}`;
158-
}
159-
160-
if (!has('before') && !has('after')) {
161-
type = `Contextual${type}`;
162-
}
163-
164-
return type;
165-
}

0 commit comments

Comments
 (0)