Start Date Release Date Release Versions PR link Tracking Link Stage Teams
11/30/2021 6/13/2022
  • ember-source: v4.5.0
Released
  • Framework

Introduce new test helpers for rendering (and re-rendering) that obviate the need for get and set

Summary

Introduce new testing utilities that remove the need for the use of this.get/this.set in test contexts. This will make rendering tests behave more like application code, and also greatly simplify the process of writing rendering tests in TypeScript since it will remove the need to append type information to this as properties are set.

Motivation

Current rendering tests require developers to set values on this in order for them to actually be present in the template that gets rendered as part of the test. For example, if we're testing that a component correctly displays a value that gets passed in as an argument, we'd do something like:

this.set('name', 'foo');

await render(hbs`<MyComponent @name={{this.name}} />`);

assert.dom('[data-test-name]').hasText(this.get('name'));

this.set('name', 'FOO');

assert.dom('[data-test-name]').hasText(this.get('name'));

This approach has three main issues:

  1. It's not consistent with any other programming model in Ember
  2. It synchronously flushes the DOM
  3. It makes writing tests in TypeScript a huge pain

On the first point: this.set is essentially irrelevant everywhere else in Ember. In a post-Octane world, we no longer need to get or set, and it is, in fact, frequently discouraged as an unnecessary complication. Additonally, the value of this inside of a test is extremely murky. The fact that this is both the test context and a sort of ersatz scope for the surrounding "template" of the rendering test is not exactly intuitive or easy to teach.

On the second point: this.set is actually run-wrapped when called in tests, which means that every time someone calls this.set, they are actually synchronously flushing the entirety of the DOM and triggering a re-render of the entire test template. This in no way resembles the relationship between data updates and re-renders in application code. In application code, changes to the underlying data in template are coalesced into a single DOM update, which, in turn, re-renders only the parts of the DOM that are affected by the changed data. As a result, rendering tests end up essentially testing a version of rendering that isn't even used in the application being tested.

On the third point: having to set properties on this leads to a whole bunch of papercuts when writing tests in TypeScript. As discussed in the ember-cli-typescript docs, rendering tests require developers to arbitrarily add (and change) properties on this. Developers thus have to define (and re-define) the interface for this to accommodate all of those properties to prevent TypeScript from complaining that each of the values that were set don't exist on the TestContext type. Beyond simply being annoying, this actually makes it impossible to write safe and useful types spanning multiple tests: properties declared for one test appear to be available in all of the tests in a module (and get autocompleted accordingly), whether or not they actually are available.

The goal of this RFC, then, is to greatly simplify the testing model by removing the need for get and set in rendering tests, thereby reducing the reliance on this.

Detailed design

We will introduce two new/refactored utilities for use in rendering tests:

  1. Update the render helper to accept either a component or a template snippet.
  2. A new rerender function that would be used after the initial render call to wait for DOM updates

These additions, used in concert with First Class Component Templates, should significantly improve the DX of writing component tests in Ember.

Updated render helper

Rendering tests currently work by rendering a template that is bound to the this of the surrounding test. This is why developers have to call this.set in the first place, since the value of this in a given test is doing double duty as both the actual test context AND the backing object for the template that eventually gets rendered. Since we want to move away from this very behavior, we need a version of render that can alternatively accept a fully-formed component, since components have their own context and are self-contained.

The updated version of render would be imported from @ember/test-helpers and have the following signature:

function render(template: TemplateFactory | Component, options?: RenderOptions): Promise<void>;

Since this new version of render will need to differentiate between components and templates, we'll also add an isComponent utility to the @glimmer/runtime package. This addition will significantly reduce the amount of private API that would be required when implementing the new render function.

Additionally, since passing a component to render also precludes the user from being able to access the text context via this (since components will use their own local context rather than the test's), we'll display a warning message if someone passes a component to render while also using this.set in the same test. This will help avoid confusion in the case that they opt in to the new component-based testing model but then copy an example from the guides or an older writeup that uses this.set.

rerender

Finally, we need to handle cases where you want to update the backing data and assert against the resulting changes in the rendered output. In the current testing paradigm, we'd usually just update the values using this.set, which immediately triggers a rerender. In more complex cases, e.g. where there is some async operation involved, we would wait for the state we want to assert against, likely using either settled or waitFor. This works, but has the downside of either waiting for everything to finish (in the case of settled), or waiting for a single thing to finish (in the case of waitFor).

Instead, we propose adding a new rerender function to @ember/test-helpers that exclusively waits on pending render operations, but ignores all other settledness metrics:

rerender(): Promise<void>

In order to implement rerender, we would also expose the work done by @rwjblue on renderSettled as public API in a new @ember/renderer module so that it could then be imported and used by @ember/test-helpers without issuue.

When combined with tracked state, this new rerender function allows developers to make updates to their component state and await the newly rendered version of their component without having to also wait for other pending timers or run loops. In other words, they can still assert against changes in the DOM, but would also be able to assert against things like loading states without having to use waitFor since rerender would not wait for things like async operations to complete.

Continuing the example from above, here's what we'd expect an update and re-render to look like:

import { render, rerender } from '@ember/test-helpers';
import { tracked } from '@glimmer/tracking';

// setup elided
test('it works', async function (assert) {
  const somePerson = new class {
    @tracked name = 'Zoey',
    @tracked age = 5,
  };

  const component = <template>
    <ProfileCard @name={{somePerson.name}} @age={{somePerson.age}} />
  </template>;

  await render(component);

  assert.dom(this.element).hasText(somePerson.name);

  somePerson.name = 'Zoeyyyyyyyyyyyyy';

  await rerender();

  assert.dom(this.element).hasText(somePerson.name);
});

It's worth noting that rendering tests without get/set are actually possible today using a combination of the precompileTemplate function from @ember/template-compilation, a scope object containing all of the relevant values referenced in the template, and the settled function from @ember/test-helpers. Accordingly, the implementation of this RFC would largely involve exposing these existing APIs in a way that is both more user-friendly and better-suited to both First-Class Component Templates and TypeScript, rather than introducing a significant amount of new internal functionality.

How we teach this

We'd need to add a new TypeScript-specific section to the testing components section of the official guides to document this new approach for TypeScript users.

We expect there will be a future RFC that introduces a form which also allows passing arguments into the invoking component, in addition to the approach described in this RFC that requires a backing class in order to mark primitives as tracked. As a result, for the time being, we'll only teach this new approach in a TypeScript-specific subsection of the guides on testing components until that followup RFC has been written and merged. We believe that the benefits to the TypeScript community (namely, no longer needing to fight with the type checker over the type of this in each test) still significantly outweigh the costs of the slightly awkward method of passing arguments.

We'd also need to update the API docs for @ember/test-helpers for the new version of render as well as the newly introduced rerender function.

API documentation for the two new functions are included below:

render

/**
  Renders the provided template or component and appends it to the DOM, overwriting any previously-rendered template or component.
  @public
  @param {CompiledTemplate|Component} template the template or component to render
  @param {RenderOptions} options options hash containing engine owner ({ owner: engineOwner })
  @returns {Promise<void>} resolves when settled
*/
render(template: CompiledTemplate | Component, options?: RenderOptions): Promise<void>;

rerender

/**
  Returns a promise that resolves when rendering has settled.  Settled in this context is defined as when all of the
  tags in use are "current". When this is checked at the _end_ of the run loop, this essentially guarantees that all
  rendering is completed.
  @public
  @returns {Promise<void>} resolves when settled
*/
rerender(): Promise<void>

All things considered, the changes in this RFC should result in a component testing model that is ultimately more intuitive. We could also introduce a configuration option to @ember/test-helpers that would allow users to turn on an assertion that prevents rendering tests from being written using the old method to help codebases migrate.

Drawbacks

Ember's testing setup is one its most valuable features, and this RFC would introduce changes to a testing model that already works fine. These changes would be introduced alongside the existing rendering test models, so they would not constitute a breaking change, but would introduce additional overhead in terms of "old vs. new", similar to the overhead of Octane, or the Grand Testing Unification RFCs.

Alternatives

  • Leave things as they are.
  • Encourage people to use precompileTemplate along with the current version of render.

Unresolved questions

Optional, but suggested for first drafts. What parts of the design are still TBD?