Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make test context optionally type aware for TypeScript #1298

Merged
merged 21 commits into from
Mar 14, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add export type ContextualTest = GenericTest<{ context: any }>;, otherwise this might break some users. Same for ContextualCallbackTest

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;
}