Skip to content

Testing ​

Tests split at the point where a window renders. Everything before it runs against a mock in CI, and the rest needs a running world.

In CI ​

ts
import { createMockActor, withMockFoundry } from '@vttforge/testing/vitest';

const foundry = withMockFoundry();
registerMyModule();

foundry.callHook('init');
expect(foundry.settings[0].key).toBe('cacheSize');

foundry.restore();

withMockFoundry installs foundry, game, CONFIG, Hooks, ui and CONST, and hands back a handle that records what your code registered: hooks, settings, settings menus, keybindings, notifications, sheets, enrichers.

Registered settings also land in game.settings.settings, the registry Foundry keys by namespace.key. A module that enumerates or copies what a world holds reads that map, because it is the only way to reach a setting belonging to a package you did not write. Registration normalises as it stores, the way Foundry does: an unknown scope falls back to client, a missing default becomes null. Menus land in game.settings.menus.

restore() puts every global back, including deleting the ones that never existed.

Anything else your code reads goes in globals. Foundry puts every document class on the global scope, and the fixed set above does not include them:

ts
const foundry = withMockFoundry({
  globals: { JournalEntry: { create: vi.fn() } },
});

restore() clears those too, and puts back whatever was there before.

Importing from this entry also declares the globals, so game.settings in a test does not produce "Cannot find name 'game'".

Assert the sheet key ​

ts
const foundry = withMockFoundry();
registerSystem({
  id: 'my-system',
  sheets: [{ id: 'character', document: 'Actor', sheet: CharacterSheet, makeDefault: true }],
});
foundry.callHook('init');

foundry.sheets.map((s) => s.key); // ['my-system.character']

Pin the key. Foundry saves it on every document whose owner picked the sheet, and it is built from the class name, which a bundler is free to rename between builds.

foundry.enrichers reads back the same way, with the namespaced id.

Mock documents behave like documents ​

ts
const actor = createMockActor({ system: { hp: { value: 10, max: 10 } } });
await actor.update({ 'system.hp.value': 4 });

actor.system.hp; // { value: 4, max: 10 }
actor.updates;   // every delta, in order

Updates merge and dotted paths expand, because that is what Foundry does. A mock that replaced instead of merging would let a test pass while the real thing drops every sibling key.

In a real world ​

ts
import { registerBatch } from '@vttforge/testing/quench';

registerBatch('my-module.sheets', ({ describe, it, assert }) => {
  describe('character sheet', () => {
    it('renders', async () => {
      const actor = await Actor.create({ name: 'T', type: 'character' });
      await actor.sheet.render(true);
      assert.ok(actor.sheet.rendered);
      await actor.delete();
    });
  });
});

registerBatch is safe at module scope, because it waits for quenchReady before registering. Outside Foundry it does nothing, so a file holding both kinds of test still imports under Vitest.

Against a real Foundry ​

Quench runs inside a world someone already opened. The other case is a world that does not exist yet: a CI job, or a check that the package you built loads at all. @vttforge/testing/container boots one.

ts
import { startFoundryContainer } from '@vttforge/testing/container';

const foundry = await startFoundryContainer({
  acceptLicense: true,
  name: 'my-module-e2e',
  packages: [
    { kind: 'system', id: 'some-system', from: 'test/fixtures/system' },
    { kind: 'module', id: 'my-module', from: 'dist' },
  ],
});

try {
  const page = await browser.newPage();
  await page.goto(foundry.baseUrl);
  // ... join the world and drive it
} finally {
  foundry.stop();
}

It needs docker on the PATH and three variables in the environment: FOUNDRY_LICENSE_KEY, FOUNDRY_USERNAME and FOUNDRY_PASSWORD. Docker reads each by name, so no credential goes into an argument list. The first run downloads Foundry; later runs reuse the data volume and start in seconds.

Booting Foundry records an answer to its licence agreement, and this code will not answer it for you. Read the agreement, then pass acceptLicense: true or set FOUNDRY_ACCEPT_LICENSE=1.

Two things the API is shaped around. Foundry scans its packages directory once, at startup, so pass packages and let it install before the world launches; a package added later needs restart(). And the container name, the volume and the world id all default, so two runs sharing a name fight over one container. Name them per project.

Foundry runs one world at a time. createWorld({ id, title }) declares another and switchWorld(id) launches it, which is how a package that moves data between worlds gets tested in one run. A switch restarts Foundry: a browser session on the old world is gone, and world-scoped state does not carry over, so a module enabled in the first world starts disabled in the second.

This SDK's own end-to-end run uses this entry point and nothing private, so the API a consumer gets is the API the repo tests with.

Where to draw the line ​

Anything before _renderHTML is testable with a mock. Real rendering, sockets with two clients and documents round-tripping through the database need a running Foundry, and only a running Foundry caught several bugs in this SDK: a sheet registered but unreachable, a class extending the wrong base, an annotation layer whose CSS class name did not match what the library styles.

There is no helper that mounts a sheet against a mock actor and hands back its HTML, and there will not be one. Rendering a sheet is the Application framework: the Handlebars mixin, PARTS, template loading, Foundry's own helpers, tabs, form handling. A copy of that inside a mock would render something like what Foundry renders, and a test that passes against the copy and fails in the real thing is worse than no test. Test the context with _prepareContext under Vitest, and test the render under Quench or in a container.

MIT licensed.