| name | discourse-writing-js-tests |
| description | Write and structure JavaScript/QUnit tests for Discourse core, plugins, and themes. Use when creating or modifying unit tests (lib/utility/service/model), component rendering tests, integration tests, or acceptance tests. Covers module naming, setupTest/setupRenderingTest/setupApplicationTest, fixtures, the qunit-helpers toolbox, pretender, and qunit-dom assertions. |
Writing JavaScript (QUnit) Tests
Discourse uses QUnit with ember-qunit and
@ember/test-helpers. Assertions use
qunit-dom (assert.dom(...)).
Testing Principles
- Test behavior, not implementation — assert on rendered output, DOM state, and public
return values, not internal component state or private methods.
- One concept per
test — each test() verifies one behavior for clear failure diagnosis.
- Prefer
assert.dom(...) over manual DOM querying. It produces better failure messages
and waits-free, synchronous DOM reads. See the qunit-dom API.
- Always
await interactions — render, click, fillIn, visit, settled, etc. are
async. Forgetting await causes flaky tests.
- Always pass a description as the last argument to assertions — it documents intent and
pinpoints failures:
assert.dom(".foo").exists("the widget renders").
- Keep tests independent — global state is reset between tests by
testCleanup (see
qunit-helpers.js); don't rely on order or leak registrations.
- Don't over-stub — stub network boundaries via pretender, not internal collaborators.
File locations & naming
| Type | Location | Setup helper |
|---|
| Unit (lib/utility/service/model) | frontend/discourse/tests/unit/** | setupTest |
| Component rendering | frontend/discourse/tests/integration/components/** | setupRenderingTest |
| Other integration | frontend/discourse/tests/integration/** | setupRenderingTest/setupTest |
| Acceptance (full app) | frontend/discourse/tests/acceptance/** | acceptance(...) |
| Plugin tests | plugins/<name>/test/javascripts/** | same helpers |
Test files end in -test.js or -test.gjs (use .gjs when the test renders a component template).
Module naming convention
The module(...) title is a |-separated hierarchy. The last segment is the subject.
For components, the subject must be the modern PascalCase invocation name — matching
how Ember invokes the component (<PollInfo />), not the kebab-case filename:
module("Integration | Component | PollInfo", function (hooks) { ... });
module("Component | ChatChannelCard", function (hooks) { ... });
module("Integration | Component | SelectKit | ComboBox", ...);
module("Integration | Component | Post | Menu | PostUsersMenu", ...);
module("Integration | Component | poll-info", ...);
module("Integration | Component | select-kit/combo-box", ...);
Non-component subjects (Lib, Utility, Service, Model, Controller, Route) stay
kebab-case, matching their filenames:
module("Unit | Lib | singleton", ...);
module("Unit | Utility | user-search", ...);
module("Unit | Service | site-settings", ...);
module("Unit | Model | topic", ...);
Common prefixes: Unit | <Kind> | <subject>, Integration | Component | <PascalName>,
acceptance tests are auto-prefixed with Acceptance: by the acceptance() helper.
Unit tests (setupTest)
For libs, utilities, services, models — no rendering.
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import singleton from "discourse/lib/singleton";
module("Unit | Lib | singleton", function (hooks) {
setupTest(hooks);
test("current returns a memoized instance", function (assert) {
const current = SomeModel.current();
assert.strictEqual(current, SomeModel.current());
});
});
Look services/objects up from the owner: getOwner(this).lookup("service:site-settings").
Component rendering tests (setupRenderingTest)
Import setupRenderingTest from discourse/tests/helpers/component-test (the Discourse
wrapper), NOT directly from ember-qunit. The wrapper also sets this.siteSettings,
this.site, this.session, and a logged-in this.currentUser for you.
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import BookmarkIcon from "discourse/components/bookmark-icon";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { i18n } from "discourse-i18n";
module("Integration | Component | BookmarkIcon", function (hooks) {
setupRenderingTest(hooks);
test("with reminder", async function (assert) {
const store = this.owner.lookup("service:store");
const bookmark = store.createRecord("bookmark", { name: "some name" });
await render(<template><BookmarkIcon @bookmark={{bookmark}} /></template>);
assert.dom(".d-icon-discourse-bookmark-clock").exists();
assert.dom(".svg-icon-title").hasAttribute("title", i18n("bookmarks.created"));
});
});
- Prefer
.gjs with inline <template> so you can import and invoke the real component.
- Options:
setupRenderingTest(hooks, { anonymous: true }) for an anonymous user;
{ stubRouter: true } to stub service:router.
- Interact with
@ember/test-helpers: click, fillIn, triggerKeyEvent, settled, find.
- For select-kit and FormKit widgets, use
discourse/tests/helpers/select-kit-helper and
discourse/tests/helpers/form-kit-helper rather than poking the DOM directly.
Acceptance tests (acceptance)
Full-application tests that visit() routes. Use the acceptance() helper from
qunit-helpers — it wires up setupApplicationTest, the default pretender, site, settings,
and per-test cleanup. Configure the scenario through the needs argument.
import { visit } from "@ember/test-helpers";
import { test } from "qunit";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
acceptance("Topic Notifications button", function (needs) {
needs.user();
needs.settings({ enable_foo: true });
needs.site({ categories: [...] });
needs.mobileView();
needs.pretender((server, helper) => {
server.post("/t/280/notifications", () => helper.response({}));
});
test("updates the notification level", async function (assert) {
await visit("/t/internationalization-localization/280");
assert.dom(".topic-notifications-button").exists();
});
});
needs.* options: user(overrides), pretender(fn), site(changes), settings(changes),
mobileView(). The active QUnit hooks are available as needs.hooks.
Network stubbing (pretender)
Discourse ships a large default Pretender server
(frontend/discourse/tests/helpers/create-pretender.js) that answers common endpoints. Add or
override routes with needs.pretender(...) (acceptance) or pretender/applyPretender
patterns for other types. The helper.response(body) / helper.response(statusCode, body)
builders shape responses.
Fixtures
Canned API payloads live in frontend/discourse/tests/fixtures/** and back the default
pretender. Import a fixture to seed models or assertions instead of hand-building JSON:
import sessionFixtures from "discourse/tests/fixtures/session-fixtures";
import siteFixtures from "discourse/tests/fixtures/site-fixtures";
currentUser() (from qunit-helpers) builds a User from the session fixture.
The qunit-helpers toolbox
frontend/discourse/tests/helpers/qunit-helpers.js exports broadly useful helpers:
- Users/session:
acceptance, currentUser(), logIn(owner), loggedInUser(),
updateCurrentUser(props), resetSite(extras).
- MessageBus:
publishToMessageBus(channel, ...args) — drive realtime updates, then assert.
- Time:
fakeTime(timeString, tz, advance) and withFrozenTime(timeString, tz, cb) (sinon
fake timers). Remember to restore (withFrozenTime does it for you).
- Input simulation:
createFile(name, type), paste(selector, text),
selectText(selector), simulateKey(el, key) / simulateKeys(el, keys), metaModifier.
- Conditional tests:
conditionalTest, chromeTest, firefoxTest.
- Legacy DOM helpers (jQuery-backed):
query(), queryAll(), exists(), count(),
visible(), invisible(), fixture(). Prefer assert.dom(...) and find() in new
tests — reach for these only when matching existing style.
- Deprecated:
discourseModule — use QUnit's module instead.
Custom assertions
Beyond qunit-dom's assert.dom:
assert.present(value, msg) / assert.blank(value, msg) — Ember isEmpty checks.
assert.containsInstance(collection, klass, msg).
- Domain assertions registered at import time:
assert.form() (FormKit, see
form-kit-assertions.js), assert.dselect() (d-select-assertions.js),
assert.notificationsTracking() (notifications-tracking-assertions.js).
Running tests
bin/qunit --help
bin/qunit path/to/some-test.gjs
bin/qunit path/to/integration/components
bin/qunit -f "BookmarkIcon"
bin/qunit --target chat
Requires a running Rails server (or pass --standalone to spin up an isolated one).
Before committing
Always lint changed test files:
bin/lint --fix --recent