Skip to content

Contexts

Anton edited this page Sep 2, 2019 · 1 revision

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

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.

Each Directory is a Test Suite

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.

Context as Alternative Solution

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.

Object Context

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.

Class Context

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.

Multiple Contexts

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 and B, the test test caseA(A, B) will have both contexts evaluated and available to it, testCaseB(A) will only have context A evaluated, and testCase() will not lead to evaluation of any contexts. This means that functions with variable lengths like test(...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.

Persistent Context

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.

Clone this wiki locally