The value and benefit of assert.expect()

Hi! I’m Timo Tijhof, the project lead for upstream QUnit.

First off, I’d like to say thank you for many years of useful and kind upstream feedback, bug reports, and pull requests.

I’d like to hear from you in the Ember community about assert.expect() and how you like (or don’t like) its use. For example, does it give you confidence when writing tests? Perhaps you recall a specific test where it helped catch a mistake? I’d love to hear these stories.

The reason I care, is that I’m considering to make a clearer default in the QUnit manual and ecosystem about best practices. My current inclination is to not actively promote assert.expect(). It will forever be part of the QUnit API and work perfectly for existing code, but I suspect it is generally of neutral or (slightly) net-negative value to a new project. If I learn here that there is still much value from mass adoption, I might reconsider this!

History

assert.expect() was introduced in QUnit 1. Back then, testing callback-heavy code was not standardised and difficult to do in a reliable fashion. ES6 async-await would not be standardised for another ten years. The only way JavaScript could run code (i.e. an assertion) after something async, was through a callback function. This meant that even when the test function has returned and all assertions upto there passed, there may be other functions that you hoped would run as well, but perhaps didn’t.

QUnit.test('example', function (assert) {
  var x = new Gump();
  x.on('change', function (state) {
   assert.equal(state, 'running');
  });
  x.on('end', function () {
   assert.true(true);
  });
  x.run();

  assert.equal(x.name, 'Forrest');
});

In the above example, if x.run() has a regression and doesn’t invoke the change or end events, the test would still pass. Likewise, if it was working but was asynchronous, we need a way to “wait” for it.

The affordances for this in QUnit 1 were assert.async() for async code (originally QUnit.stop and QUnit.start), and assert.expect() more generally for nested assertions (async or not).

QUnit.test('example', function (assert) {
  assert.expect(3);            // <
  const done = assert.async(); // <

  var x = new Gump();
  x.on('change', function (state) {
   assert.equal(state, 'running');
  });
  x.on('end', function () {
   assert.true(true);
  });
  x.run(done); // <

  assert.equal(x.name, 'Forrest');
});

Today

  • QUnit.test() accepts async functions (since QUnit 1.16 in 2014). You can rely on QUnit tracking the async test, waiting for it to complete (or timeout), and failing the test suite accordingly. This works both with modern ES6 async-await, and in ES5 where you can return the asyn chain as a Thenable from the test function.
  • assert.timeout() lets you enable or change a timeout. Especially in large code bases where perhaps a global timeout can’t be set yet, you can opt-in with an enforced or shorter timeout in invidivudal tests.
  • assert.rejects() is like assert.throws() but for async functions and other promises/thenables.
  • assert.verifySteps() represents the philosophy that nested assertions can and should be avoided.

Hypothesis A: For the majority of tests, both sync and async, failure scenarios are naturally caught. Either as failed assertions, uncaught error, async rejection, or timeout.

The notable exception is nested assertions. Testing data that originates from callback is a frequent need when testing Ember applications. JavaScript is inherently event-driven, and this comes up also when testing vanilla JavaScript code that uses the DOM API, or Node.js apps that use EventEmitter, jQuery plugins, Web Components, etc.

Hypothesis B: Avoiding nested assertions leads to tests that are more insightful, more reliable, and more strict; compared to counting nested assertions.

How (Part 1)

In the cases I’ve come across so far, I found assert.verifySteps() to generally fit best. It has the benefit of being built-in, discoverable, easy to adopt in existing code, with no extra code to make it work.

QUnit.test('example', async function (assert) {
  const x = new Gump();
  x.on('change', function (state) {
   assert.step(state);
  });
  x.on('end', function (distance) {
   assert.step('end');
  });

  await x.run(); 

  assert.equal(x.name, 'Forrest');
  assert.verifySteps(['running', 'end']);
});

Real example from MediaWiki:

QUnit.test('multiple mw.hook consumers with memory', function (assert) {
	mw.hook('test.multiple' )
		.add(function (data) {
			// Listen for future events (no memory yet)
			assert.step('early ' + data);
		})
		.fire('x')
		.fire('y')
		.fire('z')
		.add(function (data) {
			// Start with memory from last fire
			assert.step('late ' + data);
		});

	assert.verifySteps([
		'early x',
		'early y',
		'early z',
		'late z'
	]);
} );

How (Part 2)

Some projects prefer a more idiomatic and JavaScript-native solution. In the below examples I apply local variables and assert.deepEqual, instead of the Step API used above.

Example adapted from Ember.js Guides (original)

test('should trigger external action', async function(assert) {
    let value;
    this.set('externalAction', (data) => {
      value = data;
    });

    await render(hbs`<CommentForm @submitComment={{this.externalAction}} />`);

    await fillIn('textarea', 'You are not a wizard!');
    await click('.comment-input');

    assert.deepEqual(value, { comment: 'You are not a wizard!' }, 'value');
  });

Example from MediaWiki:

QUnit.test('mw.track', function (assert) {
	var events = [];
	mw.trackSubscribe('foo', function (topic, data) {
		events.push([ topic, data ]);
	});
	mw.track('foo', { key: 1 });
	mw.track('foo', { key: 2 });

	assert.deepEqual(events, [
		[ 'foo', { key: 1 } ],
		[ 'foo', { key: 2 } ]
	], 'Events');
});
QUnit.test('start hidden and become visible', function (assert) {
	mockDocument.hidden = true;

	let called = 0;
	mw.visibleTimeout.set(function () {
		called++;
	});
	assert.strictEqual(called, 0);

	mockDocument.toggleVisibility();
	assert.deepEqual(called, 1);
});

I’d also like to draw attention to lack of ordering strictness and (if not using assert.expect) a lack of possibly inintended repeat events.

// Given: "start" is emitted after "change"
// Given: "change" is emitted twice
//> Result: Passing
test('example', function (assert) {
  Example.on('start', function () {
    assert.true(true);
  });
  Example.on('change', function (data) {
    assert.equal(data, 'running');
  });
  Example.on('end', function () {
    assert.true(true);
  });

  Example.run();
});

// After 30s:
//> Error: Timeout reached after 30 seconds.
test('example', function (assert) {
  assert.expect(1);
  let done = assert.async();

  Example.on('change', function (data) {
    assert.equal(data, 'something');
    done();
  });

  Example.run();
});

Depending on what the application supports, structuring this as awaiting/returning a promise, or passing done to an “end” event. For cases where the only bug is that the “change” event didn’t happen, this results in a faster failure.

// Afer 0.1s:
//> Error: Expected ['something'], Actual: [].
test('example', function (assert) {
  Example.on('change', function (data) {
    assert.step(data);
  });
  await Example.run(); // or Example.on('end', …);

  assert.verifySteps([ 'something' ]);
});

Benefits

When moving the assertion out of a nested context means that it is always reached, unless the test function fails in a way that is naturally caught.

Performing the assertion in the test function, also means you are in charge of when it runs (you explicitly indicate when you expect the data to be there). In the case of async events, I find this generally encourages developers to engage with and test their own async APIs. By first waiting for when you expect the code to be done, and then performing the assertion, I find projects tend to get either more descriptive or faster feedback.

By asserting data as steps, arrays, or numbers (instead of assertion counts), we naturally perform a stricter assertion, one that also validates the order and frequency of events. In practice, I find this leads to tests that are more self-documenting and more deeply cover APIs, beyond what test coverage metrics can measure, and that would usually be considered too much effort to test explicitly on its own. But, are naturally tested when using steps or an array.

So with that, I ask for feedback on these hypotheses:

Hypothesis A: For the majority of tests, both sync and async, failure scenarios are naturally caught. Either as failed assertions, uncaught error, async rejection, or timeout. (Except nested assertions).

Hypothesis B: Avoiding nested assertions leads to tests that are more insightful, more reliable, and more strict; compared to counting nested assertions.

– Timo (Krinkle)

1 Like

Thanks for the detailed exploration here this is great. I wasn’t actually of verifySteps but I like that a lot.

I think I’d agree with both your hypotheses. We use assert.expect a decent amount in our app but it’s always in the way that you mentioned above to verify that async events happened at all/x number of times, what I think you’re referring to as “nested assertions”. Our most common use case is verifying that an action was called based on some user interaction and that when it was called it received some specific set of args. Using assert.expect never felt “right”, but it was often the only way we could be sure the test wasn’t passing illegitimately.

We have started using spies to be more specific in those cases and I think they are more natural to read and write. I think verifySteps fits in the other gap nicely (e.g. testing steps sequence). So all that to say I think anywhere we’ve used assert.expect could easily be replaced with better patterns.

I cross posted this thread in our Discord to see if any others weigh in as well.

Yes, thanks for this great, thoughtful question. I wasn’t aware of verifySteps either, but now that I know about it, I’m going to start spreading the word. It’s funny how you get used to doing familiar things without looking them up, and so never look back to discover your available options have improved.

I’ve been relatively unhappy with assert.expectfor all the time I’ve used it, as it’s been too indirect an indication of success, and I don’t like verifications being “squishy.” Using async/await language features has helped to avoid it, and verifySteps will help to lock down the remaining places my team still uses it. I can also see a few eslint rules for tests that are on the recommended list today getting adjusted to not prescribe it.

I use assert.expect exactly one way: assert.expect(0) typically when testing that some function doesn’t throw an exception. If there were an assertion like assert.doesNotThrow(callback) I’d use that instead. I realize it’s not much different than assert.true(true, 'does not throw') but I think the intent is clearer.

Other than that I’ve always avoided assert.expect and used the var events = []; pattern instead.

I like to handle the “events” manually as this approach scales from the simple case that you showed above to other kinds of situations like searching through the events array or having multiple events arrays to concurrently track different things.

1 Like

100% agree. I would even prefer if we can update the corresponding lint rule and suggest to use assert.step + assert.verifySteps instead of assert.expect.

Too often, I see engineers trying to satisfy the lint rule and add an assert.expect. While technically correct, I prefer to avoid nested assertions.

Like @dknutsen, I’ve been using assert.expect() to check things that are asynchronous in nature, but didn’t like having to manually count and update the number of expected assertions.

  • A Mirage endpoint is called with the right payload.
  • A callback function (i.e. an @action passed to a component as an argument) is called with the right input(s).

Recently, I noticed that eslint-plugin-qunit@v7 requires me to add assert.expect(1), because it counted the line await percySnapshot(assert); as an assertion. I don’t think this is quite correct, as Percy really uses assert to create the snapshot name. In eslint-plugin-qunit@v8, which made a breaking change to when assert.expect() is needed, I suddenly have to remove extraneous assert.expect()'s, including those for Percy snapshots. Not the best developer experience. :slight_smile:

I started using assert.step() and assert.verifySteps() in ember-workshop today, and do think that these form the better approach to checking asynchronous things.

3 Likes