Almost every Meteor app we are handed has the same test situation: a tests/ directory with three files from 2017, one of which no longer compiles, and a deploy process that depends on someone clicking through the app for ten minutes. That was survivable while the code sat still. It stops being survivable the moment you start a Fibers-to-async conversion, because that conversion touches nearly every server file in the codebase.
The async migration is a mechanical change with a long tail of behavioral surprises. A missing await does not throw — it hands you a Promise where you expected a document, and the failure shows up somewhere downstream, sometimes in production, sometimes only under load. You cannot review your way out of that at scale. You need something that runs.
This is not an argument for a coverage target. It is an argument for a specific, small net, built in a specific order, before the risky work starts.
Decide what the net is actually for
Write the goal down before writing tests, because it changes what you build. The goal here is narrow: detect behavior that changed when it was not supposed to change. Not design quality, not documentation, not a number in a report.
That means two things in practice. First, tests are worth writing where the conversion will touch code — server methods, publications, and any collection hook or observer. Second, tests are worth writing where a silent failure would cost money: checkout, auth, billing webhooks, whatever your app's equivalent is. Everything else can wait, possibly forever.
We usually aim for something like thirty to eighty server tests and five to ten browser paths before starting a migration. That is a small number on purpose. A net you can run in two minutes on every commit catches more regressions in practice than a thorough suite nobody waits for.
Get the runner working first, with one trivial test
On Meteor the test runner is a Meteor concern, not a plain Node one: your code imports meteor/* packages and expects a live connection and a database. In 2026 the working default is meteortesting:mocha, which replaced the old practicalmeteor:mocha package that most inherited apps still reference in .meteor/packages. If you see practicalmeteor:mocha or dispatch:mocha, that is your first replacement, and it is usually a same-day job.
meteor remove practicalmeteor:mocha
meteor add meteortesting:mocha
meteor npm install --save-dev chai
Then the two commands you will live in:
# fast unit/integration run, server and client, exits when done
TEST_WATCH=0 meteor test --once --driver-package meteortesting:mocha
# full-app mode: your real startup code, fixtures, and routes load
TEST_WATCH=0 meteor test --full-app --once --driver-package meteortesting:mocha
The distinction matters more on Meteor than on most stacks. Plain meteor test loads only files matching *.tests.js plus what they import, which is fast and keeps tests honest about their dependencies. --full-app boots the whole application including server/main.js, so your fixtures, indexes, and startup side effects exist. Method and publication tests generally want --full-app. Pure logic tests do not.
Do not start by writing a real test. Start by getting a file containing it('runs', () => {}) to pass in CI. On an older app, that alone can take a day of untangling build errors, and it is far easier to debug with nothing else in the suite.
Server tests, in the order that pays
Methods first. A Meteor method is the closest thing most of these apps have to an API contract, and methods are exactly what the async conversion rewrites. Call them the way the client does rather than importing the handler, so you exercise the real argument validation and the real this.userId behavior:
import { Meteor } from 'meteor/meteor';
import { assert } from 'chai';
describe('orders.cancel', function () {
it('refuses a cancel from a user who does not own the order', async function () {
const orderId = await seedOrder({ ownerId: 'user-a' });
const invoke = Meteor.server.method_handlers['orders.cancel'];
try {
await invoke.call({ userId: 'user-b' }, orderId);
assert.fail('expected not-authorized');
} catch (e) {
assert.equal(e.error, 'not-authorized');
}
});
});
Note the async/await in the test even if the app is still on Meteor 2. Write every new test as if the code under it were already async. Mocha handles returned Promises fine, awaiting a synchronous return value is harmless, and it means the suite does not need rewriting halfway through the migration.
Publications second. These are the other half of a Meteor app's contract and the half nobody tests, usually because collecting the output looks awkward. It is not, if you assert on the cursors a publication returns: check the selector, the fields projection, and — the thing that actually causes incidents — that the publication filters by the calling user at all. A publication test that only asserts "returns documents" will pass happily while leaking every tenant's data.
Then the pure functions. Pricing, date math, permission helpers, anything with branches and no database. These are cheap, fast, and the least likely to break during the conversion, which is exactly why they come third rather than first.
One honest caution about mocking: resist it here. The bugs an async migration produces live in the boundary between your code and the database driver, so a suite that stubs the collection layer will pass through the exact class of failure you are trying to catch. Use a real test database. meteor test gives you one per run.
Five browser paths, not fifty
Above the server tests, put a very short end-to-end suite on the paths where a silent break is unacceptable: log in, the main create/edit flow, the money path, and one page that depends on reactivity so you notice if subscriptions stop updating.
Playwright is the pragmatic default now; Cypress works equally well if the team already knows it. Point it at a running app rather than wiring it into the Meteor test driver — fewer moving parts, and it survives the version upgrades ahead. Two details specific to Meteor: log in through the UI rather than faking a session, because Meteor Accounts stores a resume token in local storage and half of auth bugs live in that handoff; and assert on rendered state after a subscription resolves rather than on fixed timeouts, since reactive re-renders arrive when they arrive.
Keep this suite small enough that it stays green. An end-to-end suite people routinely ignore is worse than no suite, because it teaches the team that red means nothing.
Wire it to CI before you need it
The net only works if it runs without anyone choosing to run it. A workable pipeline on any CI provider: install the pinned Meteor release, cache ~/.meteor, run the unit pass, run the --full-app pass, then run Playwright against a built instance. Expect the first honest run to take ten to twenty minutes on an older codebase — mostly build time, and mostly improvable later.
Two rules we hold to during a migration. Run the suite against the pre-migration code first and fix or delete anything flaky, because a test that fails intermittently is indistinguishable from an async bug and will burn a day of investigation. And convert in slices small enough that a red suite points at one thing: one module of methods per pull request, suite green before the next.
What this is worth, plainly
Adding this net costs real time — typically a week or two of senior engineering on a mid-sized app, sometimes more if the build is in bad shape. It does not make the migration faster. It makes it observable, which is what turns a multi-month conversion from a series of production surprises into a series of ordinary pull requests.
It is also the part of the work that keeps its value no matter what the app's end state is. If you stay on Meteor for another five years, you have tests. If the decision is eventually to migrate off Meteor, those method and publication tests describe the behavior the replacement has to reproduce, and during a parallel run they are the thing you point at both systems to prove they agree. Very little else we build during a migration is that reusable.