Skip to main content

Testing

Effection not only simplifies the process of writing tests, but by managing all the timing complexity of your setup and teardown, it can often make new kinds of tests possible that beforehand may have seemed unachievable.

Currently, both Jest and Mocha are supported out of the box, but because of the way Effection works, and because of the nature of task you have to do while testing, it's easy to integrate it with any test framework you like.

Lifecycle#

The reason Effection can so effectively power your tests is because the life-cycle of a test mirrors that of an Effection Task almost perfectly.

Consider that almost every test framework executes the following sequence of operations when running a test:

  1. setup()
  2. run()
  3. teardown()

While there are certainly different ways of expressing this syntactically, it turns out that as a fundamental testing pattern, it is nearly universal. Notice that, like tests, the cleanup of an effection operation is intrinsic. We can leverage this fact to associate an effection task with each test. Then, we run any operation that is part of the test as a child of that task. After it is finished, a test's task is halted , thereby halting any sub-tasks that were running as a part of it.

This means that any resources being used by the testcase such as servers, database connections, file handles, etc... can be automatically released without writing any cleanup logic explicitly into the test itself. In essense, your tests are able to completely eliminate teardown altogether and become:

  1. setup()
  2. run()

Jest and Mocha#

Effection provides packages for seamless integration between Jest and Mocha.

To get started, install from NPM

npm install --save-dev @effection/jest

Writing Tests#

Let's say we have a test case written without the benefit of effection that looks like this:

describe("a server", () => {  let server: Server;  beforeEach(async () => {    server = await startServer({ port: 3500 });  });
  it('can be pinged', async () => {    let response = await fetch('http://localhost:3500/ping');    expect(response.ok).toBe(true);  });
  afterEach(async () => {    await server.close();  });});
Cleanup

It is critical that we shutdown the server after each test. Otherwise, node will never exit without a hard stop because it's still bound to port 3500, and worse, every subsequent time we try and run our tests they will fail because port 3500 is not available!

To re-write this test case using effection, there are two superficial differences from the vanilla version of the framework.

  1. You import all of your test syntax from the effection package, and not from the global namespace.
  2. You use generator functions instead of async functions to express all of your test operations.

Once we make those changes, our test case now looks like this.

import { beforeEach, afterEach, it } from '@effection/jest';
describe("a server", () => {  let server: Server;  beforeEach(function*() {    server = yield startServer({ port: 3500 });  });
  it('can be pinged', function*() {    let response = yield fetch('http://localhost:3500/ping');    expect(response.ok).toBe(true);  });
  afterEach(function*() {    yield server.close();  });});

But so far, we've only traded one syntax for another. Now however, we can begin to leverage the power of Effection to make our test cases not only more concise, but also more flexible. To do this, we use the fact that each test case gets its own task that is automatically halted for us after it runs.

So if we re-cast our server as a resource that is running as a child of our test-scoped task, then when the test task goes away, so will our server.

import { ensure } from 'effection';import { beforeEach, it } from '@effection/jest';
describe("a server", () => {  beforeEach(function*() {    yield {      name: 'ping server',      *init() {        let server = yield startServer({ port: 3500 });        yield ensure(() => server.close())      }    }  });
  it('can be pinged', function*() {    let response = yield fetch('http://localhost:3500/ping');    expect(response.ok).toBe(true);  });});

We don't have to write an afterEach() at all anymore, because the resource knows how to tear itself down no matter where it ends up running.

This has the added benefit of making your tests more refactorable. For example, let's say that we decided that since our server is read-only, we can actually start it once before all of the tests run, so we move it to a suite level hook. Before, we would have had to remember to convert our teardown hook as well, but with Effection, we can just move our resource to its new location.

import { ensure } from 'effection';import { beforeAll, it } from '@effection/jest';
describe("a server", () => {  beforeAll(function*() {    yield {      name: 'ping server',      *init() {        let server = yield startServer({ port: 3500 });        yield ensure(() => server.close())      }    }  });
  it('can be pinged', function*() {    let response = yield fetch('http://localhost:3500/ping');    expect(response.ok).toBe(true);  });});

It is common to extract such resources into re-usable functions that can be embedded anywhere into your test suite, and you never have to worry about them being torn down. For example, we could define our server resource in a single place:

//server.jsimport { ensure } from 'effection';
export function createServer(options) {  let { port = 3500, name = 'ping server' } = options;  return {    name,    *init() {      let server = yield startServer({ port });      yield ensure(() => server.close());    }  }}

Then we can use it easily in any test case:

import { beforeAll, it } from '@effection/jest';import { createServer } from './server';
describe("a server", () => {  beforeAll(function*() {    yield createServer({ port: 3500 });  });
  it('can be pinged', function*() {    let response = yield fetch('http://localhost:3500/ping');    expect(response.ok).toBe(true);  });});

Test Scope#

As hinted at above, there are two separate levels of task in your tests: suite-scoped, and test-scoped. Effection creates one task that has the same lifetime as the beginning and end of your test suite. Any task spawned within it can potentially last across multiple test runs. By the same token, the test-scoped task is created before and halted after every single test. Any tasks spawned within it will be halted immediately after the test is finished. For example:

import { beforeAll, beforeEach, it } from '@effection/jest';import { createServer } from './server';
describe("a server", () => {  beforeAll(function*() {    // started once before both `it` blocks    // stopped once after both `it` blocks    yield createServer({ port: 3500 });  });
  beforeEach(function*() {    // started before every `it` block    // stopped after every `it` block,    yield createServer({ port: 3501 });  });
  it('can be pinged', function*() {    let response = yield fetch('http://localhost:3500/ping');    expect(response.ok).toBe(true);
    let response = yield fetch('http://localhost:3501/ping');    expect(response.ok).toBe(true);  });
  it('can be ponged', function*() {    let response = yield fetch('http://localhost:3500/pong');    expect(response.ok).toBe(true);
    let response = yield fetch('http://localhost:3501/pong');    expect(response.ok).toBe(true);  });});

Caveats#

Jest

There is currently a critical bug in Jest that causes teardown hooks to be completely ignored if you hit CTRL-C while your tests are running. This is true for any Jest test whether you're using Effection or not, but you must be careful about interrupting your tests from the command line while they are running as it can have unknown consequences.

If you'd like to see this fixed, please go to the issue and leave a comment.

Other Frameworks#

Have a favorite testing tool that you don't see listed here that you think could benefit from a first class Effection integration? Feel free to create an issue for it or drop into discord and let us know!