-
Notifications
You must be signed in to change notification settings - Fork 0
Contexts
A context is unique to each test. When added as the context
property to a test suite, it can be accessed from the test function's first argument. If there are multiple contexts, they can be accessed in subsequent arguments.
- On This Page
- Why Use Zoroaster
- Each Directory is a Test Suite
- Context as Alternative Solution
- Object Context
- Class Context
- Multiple Contexts
- Persistent Context
Zoroaster allows to write test cases as simple functions, without using framework-specific global variables such as describe
, it
, before
and after
. Save it for the after-life. Export test suites as modules and run them with zoroaster
binary.
It's much easier to organise test cases by JavaScript files in directories and not by nested function blocks in a single file. Files can be moved around much more easily and are more atomic.
Normally, a directory is a test suite because it groups files together by functionality, and as libraries' features develop, their test directory should grow more files inside -- testing new features. It's more desirable to create many smaller files sorted by directories, rather than put all tests in a single file.
However, it's understandable why one would go down the second route -- this is because the traditional frameworks have an inherent limitation in them. They force developers to reuse single set-up and tear-down functions such as beforeEach
and afterEach
within the same file because there's no way to make them run across multiple files without duplicating the code. Consider the example below to understand this point better.
A project has src
directory and is tested with mocha
, with tests in test
directory.
# project structure
- src
- test
- light
- night.js
- day.js
- earth
- sea.js
The test suites are for the night
and day
. The purpose of the beforeEach
set-up routine is to open some connections, and the purpose of the afterEach
tear-down is to make sure that all the connections are closed.
// night.js
describe('night') {
let connections
beforeEach(async () => {
connections = await makeConnections()
})
afterEach(() => {
connections.close() // ensure destruction
})
it('should be no light at night', () => {
connections.open()
connections.sendTime(0)
connections.close()
})
}
Both test suites in separate files have to repeat the same code for their set-up and tear-down routines.
// day.js
describe('day') {
let connections
beforeEach(async () => {
connections = await makeConnections()
})
afterEach(() => {
connections.close() // ensure destruction
})
it('should be light at day', () => {
connections.open()
connections.sendTime(12)
// connections.close()
// ^ although connections are not closed in the test,
// they are closed by the tear-down
})
}
It's impossible to reuse beforeEach
and afterEach
by simply creating a new file in their parent directory, such as
// test/light/set-up.js
beforeEach(async () => {
connections = await makeConnections()
})
afterEach(() => {
connections.close() // ensure destruction
})
because
- the variable
connections
are not not available in the individual test suites; - both functions will be run for higher-level test suites (such as
earth
) as well, which is not desirable.
Think of a test context which can be asynchronously initialised, and asynchronously destroyed. The context
can be reused across multiple test suites at ease. This method combines the beforeEach
and afterEach
into a controlled state for each individual test case. Just have a look at some of the examples below.
A recommended structure is to have spec
and context
directories.
# an updated project structure
- src
- test
- context
- index.js
- spec
- light
- night.js
- day.js
- earth
- sea.js
A context can and mostly will be asynchronous, but it doesn't have to be. The body of the context is the set-up for each test, i.e., beforeEach
. By assigning properties to this
, we make them available for tests. If implementation of _destroy
is provided, which can also be async, it will be called on the tear-down, i.e., afterEach
. Therefore, we decouple the context from the test.
// test/context/index.js
export default class Context {
async _init() {
this._connections = await makeConnections() // create some connections
}
async _destroy() {
await this._connections.close() // ensure destruction
}
/**
* The set of connections to be used by tests.
*/
get connections() {
return this._connections
}
}
A context is specified as a property of a test suite, and is passed as an argument to the test case functions when it's their time to execute. The context can be reused across multiple packages, for example, temp-context
makes it super easy to create temp directories for testing, and remove them.
// test/spec/light/night
import Context from '../context'
const nightTestSuite = {
context: Context,
'has no light at night'(ctx) {
await ctx.connections.open()
// night at 0
ctx.connections.sendTime(0)
}
}
A cool thing is that you can destructure the context argument and declare only the bits of the context that you're interested in.
// test/spec/light/day
import Context from '../context'
const dayTestSuite = {
context: Context,
'is light at day'({ connections }) {
// day at 12
await connections.open()
connections.sendTime(12)
}
}
Consequently, all of this means that test contexts can be tested separately, which is perfect for when it is required to ensure quality of tests.
In this section, we tried to give a brief overview of why Zoroaster with its Contexts
should become your new daily routine. The advantage is that you're more flexible in organising the test
directory which is harder with beforeEach
and afterEach
in other testing frameworks.
When specified as an object, the context it will be frozen and passed to test cases as an argument. It can also be extended by inner test suites.
import { equal } from 'assert'
import Zoroaster from '../../src'
const context = {
name: 'Zarathustra',
}
/** @type {Object.<string, (c: context)>} */
const T = {
context,
'sets correct default name'({ name }) {
const zoroaster = new Zoroaster()
equal(zoroaster.name, name)
},
innerMeta: {
// inner context extends outer one
context: {
born: -628,
},
'accesses parent context'({ name }) {
const zoroaster = new Zoroaster()
equal(zoroaster.name, name)
},
'returns correct date of birth'({ born }) {
const zoroaster = new Zoroaster()
equal(zoroaster.dateOfBirth, born)
},
},
}
export default T
example/Zoroaster/test/spec/object-context.js
✓ sets correct default name
innerMeta
✓ accesses parent context
✓ returns correct date of birth
🦅 Executed 3 tests.
A context can and most often will be a class, and to initialise it, the _init
function will be called by the test runner if present. All methods in the context will be bound to the instance of a context for each tests, therefore it's possible to use destructuring and still have methods having access to this
and thus the state of the context. Getters are also bound to the context and the variables initialised using the destructuring of the context will take their value from its initial state. Finally, the _destroy
method will ensure the tear-down of the testing context at the end of the test.
With the following simple context:
import { join } from 'path'
export default class Context {
async _init() {
// an async set-up
await new Promise(r => setTimeout(r, 50))
this._country = 'Persia'
}
/**
* A tagged template that returns the relative path to the fixture.
* @param {string} file
* @example
* fixture`input.txt` // -> test/fixture/input.txt
*/
fixture(file) {
return join('test/fixture', file)
}
/**
* Returns country of origin.
*/
get country() {
return this._country
}
async _destroy() {
// an async tear-down
await new Promise(r => setTimeout(r, 50))
}
}
The tests can use the context testing API:
import { equal } from 'assert'
import Zoroaster from '../../src'
import Context from '../context'
/** @type {Object.<string, (ctx: Context)>} */
const T = {
context: Context,
async 'returns correct country of origin'({
country: expectedOrigin,
}) {
const zoroaster = new Zoroaster()
equal(zoroaster.countryOfOrigin, expectedOrigin)
},
}
export default T
example/Zoroaster/test/spec/async-context.js
✓ returns correct country of origin
🦅 Executed 1 test.
It is possible to specify multiple contexts by passing an array to the context
property. Oftentimes, the package's main context will contain references to fixtures, or provide methods to resolve paths to fixtures, so that it is easy to access them across tests. Next, another context can be added in the array to enrich the testing API. In the following example, the first context is used to get the path of a fixture file, and the second, TempContext is used to get the location of the temporary output, as well as to read that output later.
import { equal } from 'zoroaster/assert'
import TempContext from 'temp-context'
import Zoroaster from '../../src'
import Context from '../context'
/** @type {Object.<string, (c: Context, t: TempContext)>} */
const T = {
context: [Context, TempContext],
async 'translates and saves a passage'(
{ fixture }, { resolve, read }
) {
const output = resolve('output.txt')
const zoroaster = new Zoroaster()
const path = fixture`manthra-spenta.txt`
await zoroaster.translateAndSave(path, output)
const res = await read(output)
equal(res, `
Do Thou strengthen my body (O! Hormazd)
through good thoughts, righteousness, strength (or power)
and prosperity.`
.trim())
},
}
export default T
Only contexts specified in the test functions' arguments will be evaluated. For example, if the test suite contains 2 contexts,
A
andB
, the testtest caseA(A, B)
will have both contexts evaluated and available to it,testCaseB(A)
will only have contextA
evaluated, andtestCase()
will not lead to evaluation of any contexts. This means that functions with variable lengths liketest(...contexts)
will not have any contexts evaluated for them. This is done to avoid unnecessary work when some tests in a test suite might need access to all contexts, whereas others don't.
A persistent context is evaluated once for the whole test suite, i.e., it will start once prior to tests. It by default has 5000ms to start after which the whole test suite will fail. Each persistent context will go first in the list of contexts obtained via test arguments, before non-persistent contexts.
With the following persistent context:
import CDP from 'chrome-remote-interface'
export default class PersistentContext {
async _init() {
let client
client = await CDP({
host: '172.31.12.175',
port: '9222',
})
const { Network, Page, Runtime } = client
Network.requestWillBeSent(() => {
process.stdout.write('.')
})
await Network.enable()
await Page.enable()
this._client = client
this._Page = Page
this.Runtime = Runtime
console.log('[%s]: %s', 'RemoteChrome', 'Page enabled')
}
static get _timeout() {
return 10000
}
/**
* The page opened in the browser.
*/
get Page() {
return this._Page
}
async _destroy() {
if (this._client) {
await this._client.close()
}
}
}
The tests can use the context testing API:
import { equal } from '@zoroaster/assert'
import Zoroaster from '../../src'
import PersistentContext from '../context/persistent'
/** @type {Object.<string, (ctx: PersistentContext)>} */
const T = {
persistentContext: PersistentContext,
async 'navigates to the website'({ Page }) {
const zoroaster = new Zoroaster()
const expected = await Page.navigate({ url: 'https://adc.sh' })
zoroaster.say(expected)
equal(expected, 'hello world')
},
}
export default T
example/Zoroaster/test/spec/persistent-context.js
[RemoteChrome]: Page enabled
✓ navigates to the website
🦅 Executed 1 test.
A persistent context can implement the static getter _timeout
to specify how much time it has to start-up. Otherwise, the _init
and _destroy
have 5 seconds to complete.
For an example, see how exif2css
uses persistent contexts to setup a web-server to serve images with different EXIF orientations under different routes, and communicates with a headless Chrome via Chrome Context to take screenshots: https://github.com/demimonde/exif2css/blob/master/test/mask/default.js#L49.