Skip to content
This repository has been archived by the owner on May 22, 2024. It is now read-only.

Commit

Permalink
fix: improve catch handling
Browse files Browse the repository at this point in the history
  • Loading branch information
jdx committed Jan 25, 2018
1 parent a0ebd6f commit 2ac92c3
Show file tree
Hide file tree
Showing 14 changed files with 220 additions and 174 deletions.
75 changes: 32 additions & 43 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@ import * as os from 'os'
describe('mock tests', () => {
fancy()
.mock(os, 'platform', () => 'foobar')
.it('sets os', () => {
.end('sets os', () => {
expect(os.platform()).to.equal('foobar')
})

fancy()
.mock(os, 'platform', sinon.stub().returns('foobar'))
.it('uses sinon', () => {
.end('uses sinon', () => {
expect(os.platform()).to.equal('foobar')
expect(os.platform.called).to.equal(true)
})
Expand All @@ -78,32 +78,26 @@ Catch
catch errors in a declarative way. By default, ensures they are actually thrown as well.

```js
describe('catch', () => {
describe('catch tests', () => {
fancy()
.run(() => { throw new Error('foobar') })
.catch(/foo/)
.it('uses regex', () => {
throw new Error('foobar')
})
.end('uses regex')

fancy()
.run(() => { throw new Error('foobar') })
.catch('foobar')
.it('uses string', () => {
throw new Error('foobar')
})
.end('uses string')

fancy()
.catch(err => {
expect(err.message).to.match(/foo/)
})
.it('uses function', () => {
throw new Error('foobar')
})
.run(() => { throw new Error('foobar') })
.catch(err => expect(err.message).to.match(/foo/))
.end('uses function')

fancy()
// this would normally raise because there is no error being thrown
.catch('foobar', {raiseIfNotThrown: false})
.it('do not error if not thrown', () => {
// this would raise because there is no error being thrown
})
.end('do not error if not thrown')
})
```

Expand Down Expand Up @@ -137,7 +131,7 @@ describe('nock tests', () => {
.get('/me')
.reply(200, {name: 'jdxcode'})
})
.it('mocks http call to github', async () => {
.end('mocks http call to github', async () => {
const {body: user} = await HTTP.get('https://api.github.com/me')
expect(user).to.have.property('name', 'jdxcode')
})
Expand All @@ -153,14 +147,14 @@ Sometimes it's helpful to clear out environment variables before running tests o
describe('env tests', () => {
fancy()
.env({FOO: 'BAR'})
.it('mocks FOO', () => {
.end('mocks FOO', () => {
expect(process.env.FOO).to.equal('BAR')
expect(process.env).to.not.deep.equal({FOO: 'BAR'})
})

fancy()
.env({FOO: 'BAR'}, {clear: true})
.it('clears all env vars', () => {
.end('clears all env vars', () => {
expect(process.env).to.deep.equal({FOO: 'BAR'})
})
})
Expand All @@ -177,16 +171,16 @@ describe('run', () => {
.stdout()
.run(() => console.log('foo'))
.run(({stdout}) => expect(stdout).to.equal('foo\n'))
.it('runs this callback last', () => {
.end('runs this callback last', () => {
// test code
})

// add to context object
fancy()
.run({addToContext: true}, () => { return {a: 1}})
.run({addToContext: true}, () => { return {b: 2}})
.add(() => { return {a: 1}})
.add(() => { return {b: 2}})
// context will be {a: 1, b: 2}
.it('does something with context', context => {
.end('does something with context', context => {
// test code
})
})
Expand All @@ -206,22 +200,22 @@ import chalk from 'chalk'
describe('stdmock tests', () => {
fancy()
.stdout()
.it('mocks stdout', output => {
.end('mocks stdout', output => {
console.log('foobar')
expect(output.stdout).to.equal('foobar\n')
})

fancy()
.stderr()
.it('mocks stderr', output => {
.end('mocks stderr', output => {
console.error('foobar')
expect(output.stderr).to.equal('foobar\n')
})

fancy()
.stdout()
.stderr()
.it('mocks stdout and stderr', output => {
.end('mocks stdout and stderr', output => {
console.log('foo')
console.error('bar')
expect(output.stdout).to.equal('foo\n')
Expand All @@ -241,7 +235,7 @@ import {expect, fancy} from 'fancy-mocha'
describe('has chai', () => {
fancy()
.env({FOO: 'BAR'})
.it('expects FOO=bar', () => {
.end('expects FOO=bar', () => {
expect(process.env.FOO).to.equal('BAR')
})
})
Expand All @@ -262,13 +256,13 @@ describe('my suite', () => {

setupDB
.stdout()
.it('tests with stdout mocked', () => {
.end('tests with stdout mocked', () => {
// test code
})

setupDB
.env({BAR: 'BAR'})
.it('also mocks the BAR environment variable', () => {
.end('also mocks the BAR environment variable', () => {
// test code
})
})
Expand All @@ -291,10 +285,10 @@ describe('my suite', () => {
}

testMyApp({info: 'test run a'})
.it('tests a')
.end('tests a')

testMyApp({info: 'test run b'})
.it('tests b')
.end('tests b')
})
```

Expand All @@ -308,17 +302,11 @@ A plugin is a function that receives a `next` callback that it must call to exec
Here is an example that creates a counter that could be used to label each test run. See the [actual test](https://github.com/jdxcode/fancy-mocha/blob/master/test/base.test.ts) to see the TypeScript types needed.

```js
import {expect, fancy} from '../src'

let count = 0

// next is the callback. It's useful to wrap it in a try/finally for cleanup tasks or try/catch to handle exceptions.
// context is the result of running Object.assign() on all the previously ran plugins. Used to pass data between plugins.
// prefix is the first argument passed from the test run
// we call next() with an object we want extended onto the new context. In this case, it will be: {...context, count: number, testLabel: string}
const counter = async (next, context, prefix) => {
const counter = prefix => () => {
count++
await next({count, testLabel: `${prefix}${count}`})
return {count, testLabel: `${prefix}${count}`}
}

// note that .register() MUST be called on a non-instantiated fancy object.
Expand All @@ -328,16 +316,17 @@ const myFancy = fancy
describe('register', () => {
myFancy()
.count('test-')
.it('is test #1', context => {
.end('is test #1', context => {
expect(context.count).to.equal(1)
expect(context.testLabel).to.equal('test-1')
})

myFancy()
.count('test-')
.it('is test #2', context => {
.end('is test #2', context => {
expect(context.count).to.equal(2)
expect(context.testLabel).to.equal('test-2')
})
})
)
```
118 changes: 77 additions & 41 deletions src/base.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
// tslint:disable callable-types
// tslint:disable no-unused

import * as _ from 'lodash'
import * as mocha from 'mocha'

export interface Next<O> {
(output: O): Promise<void>
}

export interface Plugin<O, A1 = undefined, A2 = undefined, A3 = undefined, A4 = undefined> {
(next: Next<O>, input: any, arg1?: A1, arg2?: A2, arg3?: A3, arg4?: A4): Promise<any>
export interface PluginBuilder<O, InitO, A1 = undefined, A2 = undefined, A3 = undefined, A4 = undefined> {
(arg1?: A1, arg2?: A2, arg3?: A3, arg4?: A4): Plugin<O, InitO>
}
export interface Plugin<O = {}, InitO = {}> {
(context: any): Promise<O> | O | void
init?(context: any): InitO
finally?(context: any): any
catch?(context: any): any
}

export interface Plugins {[k: string]: [object]}

export interface Base<T extends Plugins> {
(): Fancy<{}, T>
register<K extends string, O, A1, A2, A3, A4>(k: K, v: Plugin<O, A1, A2, A3, A4>): Base<T & {[P in K]: [O, A1, A2, A3, A4]}>
(): Fancy<{ test: typeof it, error?: Error }, T>
register<K extends string, O, InitO, A1, A2, A3, A4>(k: K, v: PluginBuilder<O, InitO, A1, A2, A3, A4>): Base<T & {[P in K]: [O, InitO, A1, A2, A3, A4]}>
}

export interface Callback<T, U> {
Expand All @@ -24,62 +31,81 @@ export interface Callback<T, U> {
export interface MochaCallback<U> extends Callback<mocha.ITestCallbackContext, U> {}

export type Fancy<I extends object, T extends Plugins> = {
it: {
(expectation: string, callback?: MochaCallback<I>): mocha.ITest
only(expectation: string, callback?: MochaCallback<I>): mocha.ITest
skip(expectation: string, callback?: MochaCallback<I>): void
}
run<O extends object>(opts: {addToContext: true}, cb: (context: I) => Promise<O> | O): Fancy<I & O, T>
end(expectation: string, cb?: (context: I) => any): void
end(cb?: (context: I) => any): void
add<O extends object>(cb: (context: I) => Promise<O> | O): Fancy<I & O, T>
run(cb: (context: I) => any): Fancy<I, T>
} & {[P in keyof T]: (arg1?: T[P][1], arg2?: T[P][2], arg3?: T[P][3], arg4?: T[P][4]) => Fancy<I & T[P][0], T>}
} & {[P in keyof T]: (arg1?: T[P][2], arg2?: T[P][3], arg3?: T[P][4], arg4?: T[P][5]) => Fancy<I & T[P][0], T>}

const fancy = <I extends object, T extends Plugins>(plugins: any, chain: Chain = []): Fancy<I, T> => {
const __it = (fn: typeof it) => (expectation: string, callback: MochaCallback<I>): any => {
return fn(expectation, async function () {
let ctx = {plugins}
const run = (extra = {}): any => {
ctx = assignWithProps({}, ctx, extra)
const [next, args] = chain.shift() || [null, null]
if (next) return next(run, ctx, ...args as any[])
if (callback) return callback.call(this, ctx)
}
await run()
})
}
const _it = __it(it) as Fancy<I, T>['it']
_it.only = __it(it.only as any)
_it.skip = __it(it.skip as any)
const fancy = <I extends object, T extends Plugins>(context: any, plugins: any, chain: Plugin<any, any>[] = []): Fancy<I, T> => {
return {
...Object.entries(plugins)
.reduce((fns, [k, v]) => {
fns[k] = (...args: any[]) => {
return fancy(plugins, [...chain, [v, args]])
const plugin = v(...args)
if (plugin.init) context = assignWithProps({}, context, v.init(context))
return fancy(context, plugins, [...chain, plugin])
}
return fns
}, {} as any),
run(opts: any, cb: any) {
if (!cb) {
cb = opts
opts = {}
run(cb) {
return fancy(context, plugins, [...chain, async (input: any) => {
await cb(input)
}])
},
add(cb) {
return fancy(context, plugins, [...chain, (input: any) => cb(input)])
},
end(arg1: any, cb: any) {
if (_.isFunction(arg1)) {
cb = arg1
arg1 = undefined
}
return fancy(plugins, [...chain, [async (next: any, input: any) => {
let output = await cb(input)
if (opts.addToContext) next(output)
else next()
}, []]])
if (!arg1) arg1 = context.expectation || 'test'
if (cb) {
chain = [...chain, async (input: any) => {
await cb(input)
}]
}
return context.test(arg1, async function () {
for (let i = 0; i < chain.length; i++) {
const handleError = async (err: Error): Promise<boolean> => {
context.error = err
i++
const handler = chain[i]
if (!handler || !handler.catch) return false
try {
await handler.catch(context)
delete context.error
return true
} catch (err) {
return handleError(err)
}
}
const next = chain[i]
try {
context = assignWithProps({}, context, await next(context))
} catch (err) {
if (!await handleError(err)) break
}
}
for (let p of chain.reverse()) {
if (p.finally) await p.finally(context)
}
if (context.error) throw context.error
})
},
it: _it,
}
}

function base<T extends Plugins>(plugins: any): Base<T> {
const f = (() => fancy(plugins)) as any
const f = (() => fancy({
test: it,
}, plugins)) as any
f.register = (k: string, v: any) => base({...plugins as any, [k]: v})
return f
}

export type Chain = [Plugin<any, any, any, any, any>, any[]][]

function assignWithProps(target: any, ...sources: any[]) {
sources.forEach(source => {
if (!source) return
Expand All @@ -100,3 +126,13 @@ function assignWithProps(target: any, ...sources: any[]) {
}

export default base<{}>({})
.register('skip', () => {
const plugin = (() => {}) as any
plugin.init = () => ({test: it.skip})
return plugin
})
.register('only', () => {
const plugin = (() => {}) as any
plugin.init = () => ({test: it.only})
return plugin
})
Loading

0 comments on commit 2ac92c3

Please sign in to comment.