Skip to content

Instantly share code, notes, and snippets.

@bukaicode
Last active February 11, 2026 20:38
Show Gist options
  • Select an option

  • Save bukaicode/c10e4f9fdd0479824fa6bed168a1cb63 to your computer and use it in GitHub Desktop.

Select an option

Save bukaicode/c10e4f9fdd0479824fa6bed168a1cb63 to your computer and use it in GitHub Desktop.
My 2026 Meteor Testing Setup

Meteor Testing in 2026

A working test setup for a decade-old Meteor application utilizing Mocha and Cypress.

  • Meteor 3.4
  • Blaze front-end
  • Windows dev environment

NPM Packages

  • chai (for unit test assertions)
  • chai-as-promised (for chai assertions on promises)
  • chromedriver (for headless browser testing with mocha)
  • cross-env (for setting environment variables on Windows)
  • cypress (for E2E testing)
  • selenium-webdriver (for headless browser testing with mocha)
  • sinon (for mocking and stubbing)
  • sinon-chai (for chai assertions on sinon)

Atmosphere Packages

  • meteortesting:mocha (newer Meteor 3.x compatible version)
  • tmeasday:acceptance-test-driver

IMPORTANT!

After upgrading to Meteor 3, I had to remove/uninstall both test-driver packages from Meteor's .meteor/packages file. Now I just use the --test-driver flag with meteor test, which will automatically add the appropriate package.

If meteortesting:mocha is installed via meteor add it appears to ALWAYS load, regardless of the test-driver specified at runtime. This caused the acceptace-test-driver to crash the server on server code refreshes.

Entry Points

I use entry points to control file loading. Here is the meteor section of my package.json:

{
  "meteor": {
    "release": "3.4.0",
    "modern": {
      "transpiler": {
        "verbose": false
      }
    },
    "mainModule": {
      "client": "client/main.js",
      "server": "server/main.js"
    },
    "testModule": "tests/main.js",
    "testOptions": {
      "inMemoryDatabase": true
    }
  }
}

Note: While testModule is loaded for both unit and E2E tests, I use conditionals so that it actually only loads test files when in unit test mode (i.e., the meteortesting:mocha driver is running).

Folder Structure

Note: For unit tests, I place them right next to the app code.

  • client (app code and unit tests)
  • common (app code and unit tests)
  • server (app code and unit tests)
  • tests
    • root: main.js (points to mocha subfolder files)
    • cypress (typical Cypress folder structure; Cypress tests live here)
    • mocha (these files just import tests from other folders)
      • client_tests.js
      • common_tests.js
      • server_tests.js
    • utils
      • chai_setup.js
      • defer_delay.js

Here is the tests/main.js:

import {Meteor} from 'meteor/meteor';
import './utils/chai_setup';
import './utils/factories';
import '../common/services/temporal/temporal_bridge';
import '../common/collection/_load';

// Only import mocha tests if we are using that test-driver.
// In full-app testing mode, Meteor still tries to load the unit tests, which we do NOT want

const isMochaRunning = typeof describe === 'function';

if (isMochaRunning) {
    console.info('--- [DreamStudio] Initializing Mocha Test Suite ---');

    require('./mocha/common_test');

    if (Meteor.isClient) {
        require('./mocha/client_tests');
    }

    if (Meteor.isServer) {
        require('./mocha/server_tests');
    }
} else {
    console.info('--- [DreamStudio] Acceptance Mode: Skipping Unit Test Imports ---');
}

And this is the chai_setup.js file:

import chai from 'chai';
import sinonChai from 'sinon-chai';
import chaiAsPromised from 'chai-as-promised';

chai.use(chaiAsPromised);
chai.use(sinonChai);

global.expect = chai.expect;

Unit Testing with Mocha

With everything set up, I run unit tests in either watch mode (with server results in the console and client results in the browser) or headless mode (with all results shown in the server console).

NPM Scripts

"test:mocha:watch": "meteor test --driver-package meteortesting:mocha --no-lint --port 3100 --settings settings-development.json --exclude-archs web.browser.legacy,web.cordova

"test:mocha:headless": "cross-env TEST_BROWSER_DRIVER=chrome meteor test --once --driver-package meteortesting:mocha --no-lint --port 3100 --settings settings-development.json --exclude-archs web.browser.legacy,web.cordova

E2E Testing with Cypress

There are two big challenges to getting Cypress tests running

  1. Making Meteor available to the Cypress environment
  2. Generating test data from the Cypress environment

I use custom Cypress commands to help with both. I also use test-only Meteor methods for generating test data. Test-only methods are conditionally loaded.

if (Meteor.isTest || Meteor.isAppTest) {
    require('./test_methods/index');
}

My test generation method (generateTestData) is long and complex, based on my test data needs. It uses dburles:factory along with manual data creation to build up the necessary data for each test suite. It takes a configuration object as an argument, which allows each test to be specific about the data it needs. I also will occasionally generate fixtures directly within a Cypress test file.

Custom Cyprss Commands

These have been cobbled together from various tutorials over the years, and more recently refined with AI help.

Cypress.Commands.add('getMeteor', getMeteor);
Cypress.Commands.add('resetData', resetData);
Cypress.Commands.add('login', login);
Cypress.Commands.add('logout', logout);
Cypress.Commands.add('queryDb', queryDb);
Cypress.Commands.add('meteorNavigate', meteorNavigate);

/**
 * Retrieves the Meteor object from the browser's window. If the Meteor object
 * is not immediately available, navigates to the application's base URL and
 * retries with proper waiting.
 *
 * @return {Cypress.Chainable<object>} A Cypress chainable that resolves with the Meteor object.
 */
function getMeteor() {
    return cy.window().then((win) => {
        if (!win.Meteor) {
            // Visit the base URL if Meteor is not available
            cy.visit('/');
            // Wait for Meteor to be available with retry logic
            cy.window({timeout: 10000}).should((win) => {
                expect(win.Meteor, 'Meteor object should be loaded').to.exist;
            });
            // Return the Meteor object after it's confirmed to exist
            return cy.window().its('Meteor');
        }
        return win.Meteor;
    });
}

/**
 * Resets the database and generates test data based on the provided configuration.
 *
 * @param {Object} [configuration={}] - The configuration object used to generate test data.
 * @return {Cypress.Chainable<any>} A Cypress chainable that resolves with the generated test data.
 */
function resetData(configuration = {}) {
    return cy.getMeteor().then((Meteor) => {
        return Meteor.callAsync('generateTestData', configuration);
    });
}

/**
 * Logs in a user using the provided configuration or a default role-based login.
 *
 * @param {Object} config - An object containing login configuration details.
 * @param {string} [config.role] - The role of the user, used to derive default email and password.
 * @param {string} [config.emailAddress] - The email address of the user. Overrides the role-based email if provided.
 * @param {string} [config.password] - The password of the user. Overrides the role-based password if provided.
 * @return {Chainable} A Cypress Chainable that resolves once the user is logged in.
 */
function login(config) {
    const defaultConfig = {
        role: '',
        emailAddress: '',
        password: '',
    };

    const {role, emailAddress, password} = {...defaultConfig, ...config};

    if (!role && !emailAddress) {
        throw new Error('login() requires either a role or emailAddress to be provided');
    }

    Cypress.log({
        name: 'Logging in',
    });

    const resolvedEmail = role ? `${role}@test.com` : emailAddress;
    const resolvedPassword = password || (role ? 'abc12345' : '');
    if (!resolvedPassword) {
        throw new Error('Password must be provided when not using role-based login');
    }

    return cy.getMeteor().then(Meteor => {
        const promise = new Cypress.Promise((resolve, reject) => {
            Meteor.loginWithPassword(resolvedEmail, resolvedPassword, (err, result) => {
                if (err) {
                    Cypress.log({
                        name: 'Login failed',
                        message: err.message,
                        consoleProps: () => ({
                            Email: resolvedEmail,
                            Error: err,
                        }),
                    });
                    return reject(err);
                }
                resolve(result);
            });
        });

        // Wrap the promise to convert it into a proper Cypress Chainable
        return cy.wrap(promise, {log: false});
    });
}

/**
 * Logs the user out of the application by invoking the Meteor.logout function.
 * It wraps the logout process in a Cypress chainable to ensure proper synchronization
 * with Cypress commands.
 *
 * @return {Chainable} A Cypress chainable resolving the result of the logout process or rejecting with an error if the logout fails.
 */
function logout() {
    Cypress.log({
        name: 'Logging out',
    });
    return cy.getMeteor().then(Meteor => {  // Added return
        const promise = new Cypress.Promise((resolve, reject) => {
            Meteor.logout((err, result) => {
                if (err) {
                    return reject(new Error(`Logout failed: ${err.message || err}`));
                }
                resolve(result);
            });
        });
        return cy.wrap(promise, {log: false});
    });
}

/**
 * Navigates to a specified route using Iron Router.
 *
 * @param {...*} args - The arguments passed to the Router's `go` method. Typically includes the route name and any additional parameters required for navigation.
 * @return {Cypress.Chainable} Returns a chainable Cypress command.
 */
function meteorNavigate(...args) {
    if (args.length === 0) {
        throw new Error('meteorNavigate requires at least one argument (route name)');
    }

    return cy.window()
        .its('Router', {timeout: 10000}) // Wait up to 10s for Router
        .should('exist')
        .then((router) => {
            if (typeof router.go !== 'function') {
                throw new Error('Router.go is not a function');
            }
            return router.go(...args);
        });
}

/**
 * Queries the database by invoking a Meteor method and returns the result as a Cypress chainable.
 *
 * @param {string} collectionName - The name of the collection to query.
 * @param {Object} selector - The MongoDB query selector to filter the documents.
 * @param {Object} options - Optional settings to modify the query, such as sort or limit.
 * @return {Cypress.Chainable<any>} A Cypress chainable that resolves with the query result.
 */
function queryDb(collectionName, selector, options) {
    return cy.getMeteor().then((Meteor) => {
        return Meteor.callAsync('queryDb', collectionName, selector, options);
    });
}

Putting It All Together

This allows Cypress test files to do something like this:

beforeEach(() => {
    cy.clearCookies();
    cy.clearLocalStorage();
    cy.clearAllSessionStorage();
    cy.resetData({
        roles: ['office'],
        enrollmentCount: 4,
        classCount: 1,
        familyCount: 4,
        tagCount: 1,
    }).then((data) => {
        cy.wrap(data).as('data');
        cy.login({role: 'office'});
        cy.meteorNavigate('classPage', {classId: data.classes[0]._id});
    });
});

Query DB Helper

The queryDb helper can be used in Cypress tests to query the database directly. Note that the Collection names must be explicityly added to the list to be queryable with this method. Also, like other test methods, this is conditionally loaded based on Meteor.isAppTest.

const collection = [
    'Camps',
    'Classes',
    'Families',
    'Tags',
    // ...and other collections you want to query in Cypress
]

/**
 * A generic method to query any collection in the database.
 * DANGEROUS: Only expose this in a test environment.
 * @param {string} collectionName - The name of the collection to query (case-sensitive).
 * @param {object} selector - The Mongo selector for the find query.
 * @param {object} [options] - The options for the find query (e.g., limit, sort).
 * @returns {Array} - An array of documents found.
 */
function queryDb(collectionName, selector = {}, options = {}) {
    check(collectionName, String);
    check(selector, Object);
    check(options, Object);

    // This dynamically gets the collection instance from its name
    const collection = collections[collectionName];

    if (!collection) {
        throw new Meteor.Error('collection-not-found', `The collection "${collectionName}" was not found.`);
    }

    // Perform the query and return the results
    return collection.find(selector, options).fetch();
}

NPM Scripts for Cypress

Cypress requires two processes. First, start the server in full-app test mode:

"test:cy:server": "meteor test --no-lint --driver-package tmeasday:acceptance-test-driver --full-app --settings settings-development.json --exclude-archs web.browser.legacy,web.cordova",

Then, start Cypress in either watch mode or headless mode:

"test:cy:watch": "npx cypress open --e2e --browser chrome",

"test:cy:run": "npx cypress run --spec \"tests/cypress/e2e/**/*\" --browser chrome",

Coverage & AI Usage

With AI's help I've gotten good unit test coverage on app code (with more work to be done on publications and collection hooks).

For Cypress E2E tests, I've made good progress, but still have lots to go. I've started with mission critical paths like account creation and payment procesing, adding less common features later.

While AI can be helpful, I think writing good tests is challenging for both humans and robots. Particularly E2E tests require a full-stack knowledge of the codebase. But once you've got some scaffolding set up, the AI gets better at filling in the blanks.

Issues

1. Missing Documentation

At the time of this writing (Feb 2026), docs.meteor.com has ZERO information about meteor test. The Meteor Guide provides some information, but it is VERY OUTDATED. Everything I've figured out is through trial and error.

2. Module Resolution Noise

Running meteor test always shows this error on the server console for me:

Unable to resolve some modules:

  "timers/promises" in /D/DreamStudio/node_modules/@sinonjs/fake-timers/src/fake-timers-src.js (web.browser)

If you notice problems related to these missing modules, consider running:

  meteor npm install --save meteor-node-stubs

Temporal API not natively supported, using polyfill

Everything seems to work fine, so it's just noise. meteor-node-stubs is already installed as a dependency.

3. Cypress Refresh Issues

Occasionally Cypress will miss changes to test files. If that happens I have to manually clear the browser cache and reload the page. I haven't been able to solve this. I think it's a race condition with Cypress's file watcher.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment