Writing Tests

Test a plugin with bun against fakes.

Plugin tests run with bun test on your computer, not on a phone. The testing preset of @unbound-app/api fakes everything a plugin gets at runtime, so a test imports your code unchanged.

Setup

Add bunfig.toml beside your manifest.json:

bunfig.toml
[test]
preload = ["@unbound-app/api/testing/preset"]

The preset finds your plugin from the nearest manifest.json above the first test file. Set UNBOUND_PLUGIN_ROOT to point it elsewhere.

ImportWhat a test gets
@unbound-app/i18nYour real i18n/*.json, with an ICU formatter. en-US until you switch
@unbound-app/settingsYour settings.ts with its defaults, in an in-memory bucket
@unbound-app/api and every subpathFakes that record patches, commands, toasts, dialogs, Flux and assets. Also fakes metro, native, plugins, ui, utils and fs
react-nativeEach primitive as a plain node found by name, plus Animated, Share, Dimensions, NativeEventEmitter, processColor, AccessibilityInfo, AppRegistry, AppState and NativeModules. No install needed
*.png, *.svg, *.svg?assetA stable asset id for an image, a component for an SVG

It also installs the Addon scope: addon from your manifest, patcher, commands, logger, React, and the scoped timers.

Before each test, every fake resets: no patches, commands, listeners, timers, registered modules, files, toasts or dialogs; default settings; iOS; en-US. You write no setup.

First Test

tests/index.test.ts
import { startPlugin, stopPlugin, toasts } from '@unbound-app/api/testing';
import { settings } from '@unbound-app/settings';
import { expect, test } from 'bun:test';

test('greets with the plugin name', async () => {
	await startPlugin();

	expect(toasts.shown).toMatchObject([{ content: 'My Plugin is running.' }]);
	await stopPlugin();
});

test('stays quiet when greetOnStart is off', async () => {
	settings.greetOnStart = false;
	await startPlugin();

	expect(toasts.shown).toEqual([]);
	await stopPlugin();
});

The setting change in the second test does not leak into any other test.

Reference

Import these from @unbound-app/api/testing. Each recorder shows only the current test.

ExportWhat it does
startPlugin(context?)Imports src/index.tsx (or .ts) and runs start with a PluginContext
stopPlugin()Runs stop, removes what the Addon scope recorded, rejects if anything is left
pluginHolds()Each patch, command, listener and timer added since startPlugin() and still held
render(element, options?)Draws a component with real hooks. See Rendering
setLocale(locale)Changes the language for the rest of the test
extendApi(namespace, members)Adds or replaces members of a fake
toasts.shownEach toast's options. A shorthand such as toasts.success('SAVED', vars) also records key and vars, with title in the active locale
dialogs.openedEach dialog, with its title key for confirm and alert, and respond(answer)
commands.registered, commands.run(path, options?, context?)Registered commands by name, and a call as if the user sent it
patches.appliedEach held patch: kind, target, method, and pending while it waits for its module
flux.dispatched, flux.seed(stores), flux.emit(name), flux.dispatch(action)Dispatched actions, seeded stores, and sending an action to the plugin's listeners
logs.entriesEach line the plugin's logger wrote
metro.register(module, path?)Gives the plugin a Discord module to find
nativeThe fake bridge. See Testing Native Code
plugins, fsOther plugins and in-memory files

Start and Stop

stopPlugin() removes everything the Addon scope recorded: patcher patches, commands, flux, metro, storage and assets listeners, and scoped timers. A stop that forgets one of these still passes, as on a phone. Anything else left is a leak, and stopPlugin() rejects. The only leak the fake detects is a change listener added straight to a Flux store:

My Plugin left 1 thing(s) behind after stop():
  - UserStore.addChangeListener

Fix it with flux.onStoreChange(store, listener), which Unbound removes for you.

To check a feature undid itself without stopping, such as after a setting turns it off, read pluginHolds():

import { pluginHolds, startPlugin } from '@unbound-app/api/testing';
import { settings } from '@unbound-app/settings';
import { expect } from 'bun:test';

await startPlugin();
expect(pluginHolds()).toContain('flux.onAction(MESSAGE_CREATE)');

settings.greetOnStart = false;
expect(pluginHolds()).toEqual([]);

Pass a context to change platform, capabilities, or the native members your plugin calls. Other bridge members are empty objects.

import { startPlugin } from '@unbound-app/api/testing';

await startPlugin({ capabilities: ['native.ffi.call'], native: { apiVersion: '2.0.0' } });

Self-Test

The device matrix reads each plugin's self-test after a restart. Unbound builds it for you: a warning or error the plugin's logger wrote during start fails it, with that line as the detail. So a feature that skips because Discord moved a module fails the self-test without any code of yours.

Test that warning with metro.markMissing, which makes a lookup by that prop, path or name return undefined, as on a Discord build that lacks the module:

tests/character-counter.test.tsx
	test('skips the counter with one warning when Discord moved ChatInputs', async () => {
		await stopPlugin();
		metro.markMissing('getBestActiveInputForChannelId');
		await startPlugin();
		draft.text = 'hello';

		expect(renderComposer().findAll('Text')).toEqual([]);
		expect(logs.entries).toContainEqual({
			level: 'warn',
			args: ['Counter skipped: Discord moved ChatInputs or FloatingChatInputContainer.'],
		});
	});

Export selfTest only to check more than a clean start, such as that something is drawn on screen. Unbound still fails the plugin first when it warned while starting.

src/index.tsx
let drawn = 0;

export function selfTest() {
	return drawn > 0
		? { ok: true, detail: `${drawn} badges drawn on screen` }
		: { ok: false, detail: 'no badges are drawn on screen', nothingDrawn: true };
}

Module state such as drawn survives between tests. Reset it in beforeEach.

Checking Results

Run a command with its name, parsed options, and optionally the channel and guild:

import { commands, startPlugin } from '@unbound-app/api/testing';
import { expect } from 'bun:test';

const user = { id: '1', username: 'wumpus' };

await startPlugin();
expect(await commands.run('star', { user })).toEqual({ ephemeral: true, content: 'Starred wumpus.' });

Answer a dialog with respond: true confirms or presses the first button, false cancels, a string presses the button with that label.

import { dialogs } from '@unbound-app/api/testing';
import { expect } from 'bun:test';

declare function clearHistory(): Promise<boolean>;

// clearHistory() asks with dialogs.confirm before it deletes.
const cleared = clearHistory();

dialogs.opened[0]?.respond(true);
expect(await cleared).toBe(true);

Fake Discord

In a test, metro finds only modules the test registers, by the same props as on a phone. Register before startPlugin():

import { metro, patches, startPlugin } from '@unbound-app/api/testing';
import { expect } from 'bun:test';

const MessageActions = { sendMessage: (content: string) => content };

metro.register(MessageActions);
await startPlugin();
expect(patches.applied).toMatchObject([{ kind: 'before', method: 'sendMessage' }]);

Finders return a lazy handle, so a lookup made at import finds a module registered later. A patch on it waits (pending: true) and applies once a matching module registers, in this test or a later one. A patch removed first never applies. patcher.react waits the same way.

import { metro as fake } from '@unbound-app/api/testing';
import { metro, patcher } from '@unbound-app/api';

const Messages = metro.findByProps('sendMessage'); // at import, or in start()

if (Messages) {
	patcher.after(Messages, 'sendMessage', () => 'patched'); // waits: no module yet
}

fake.register({ sendMessage: () => 'sent' }); // applies now

{ lazy: false } returns the module now, and { all: true } every match. any, all and not reject a byFilePath filter.

NamespaceFake
metro.apiMessages, Linking, Profiles, AsyncUsers, Invites, OAuth2, ActionSheets, UserProfiles, found once a module with their props registers
metro.commonThe same, such as Theme, Clipboard, Moment. React and ReactNative are the fakes
metro.storesAccessibility, Theme, Users, Guilds, seeded with flux.seed
metro.componentsAny component by name. See below
metro.filtersbyProps, byName, byDisplayName, byTypeName, byFilePath, all, any, not
utils, utils.reactThe client's own findComponent, filterComponents, findInTree, filterInTree, shallow, useShallow

Components

Discord, Forms, BottomSheets and the other modules in @unbound-app/api/metro/components give any component by name, such as Discord.Text or BottomSheets.ActionSheetRow.Icon. Each draws its children and is found by name with its props: find('ActionSheetRow.Icon').

  • Discord.Button draws its text; RowButton and TableRow draw label and subLabel.
  • Discord.createStyles(styles) returns a hook giving styles, calling a factory with no theme.
  • Replace one by assignment: Discord.Text = MyText.
  • @unbound-app/api/ui rows such as SwitchRow, Page and SchemaPanel work the same.

Other Plugins

plugins.get(id), getAll, isEnabled, addons and errors read what the test sets. The plugin under test is there, started and enabled.

import { plugins } from '@unbound-app/api/testing';

plugins.set('chat-bubbles', { name: 'Chat Bubbles', enabled: true });
plugins.set('chat-bubbles', { started: false });
plugins.set('broken', { error: new Error('boom') }); // `failed`, and in `plugins.errors`
plugins.remove('chat-bubbles');

enable, disable, toggle, start and stop change what the handles report. install, reload and update resolve at once and change nothing.

Files

fs keeps files in memory under /documents (fs.documentsPath):

import { fs } from '@unbound-app/api/testing';

fs.files.set('/documents/notes.txt', 'hi');

Extending a Fake

mock.module('@unbound-app/api', ...) replaces every fake. To add or replace members only, use extendApi with a namespace, dotted for nested ones. It throws on an unknown namespace.

import { extendApi } from '@unbound-app/api/testing';

declare const MyComponent: () => null;

extendApi('metro.components', { Extra: MyComponent });
extendApi('metro', { custom: { value: 1 } });

Rendering

render(element, options?) draws function components, memo, forwardRef, context, and class components with their lifecycle methods. Options:

OptionSets
settingsSome settings, sections in part. The rest keep defaults
localeThe language @unbound-app/i18n reads
storesFlux stores by name, with or without Store, read by flux.getStore and flux.useStore
ReturnsDoes
find(match)First matching node. Throws, naming what did render, if none
findAll(match)Every matching node
text()All rendered text, joined
rerender(element)Draws a new element in place, keeping state
unmount()Removes the tree and runs effect cleanups
act(callback)Runs an interaction, then draws once

match is a name (a react-native primitive like 'Text', a displayName or function name) or a predicate. Each node has props, children, parent, and its own find, findAll and text.

src/components/star-count.tsx
import { useSetting } from '@unbound-app/settings';
import { Pressable, Text } from 'react-native';
import { flux } from '@unbound-app/api';
import { useState } from 'react';

const UserStore = flux.getStore('User');

export default function StarCount() {
	const [stars, setStars] = useState(0);
	const accent = useSetting('appearance.accent');
	const username = flux.useStore(UserStore, (store) => store.getCurrentUser()?.username);

	return (
		<Pressable onPress={() => setStars(stars + 1)}>
			<Text style={{ color: accent }}>
				{username}: {stars} {stars === 1 ? 'star' : 'stars'}
			</Text>
		</Pressable>
	);
}
tests/star-count.test.tsx
import { render } from '@unbound-app/api/testing';
import { expect, test } from 'bun:test';

import StarCount from '~/components/star-count';

test('counts each press', () => {
	const view = render(<StarCount />, {
		settings: { appearance: { accent: '#FF0000' } },
		stores: { User: { getCurrentUser: () => ({ id: '1', username: 'wumpus' }) } },
	});

	view.act(() => view.find('Pressable').props.onPress?.(Object.create(null)));

	expect(view.text()).toBe('wumpus: 1 star');
	expect(view.find('Text').props.style).toMatchObject({ color: '#FF0000' });
});
  • A component re-renders when its state, a useSetting value, or a store it reads changes.
  • To change a store mid-test, call flux.seed again. Import it as flux as fakeFlux beside your plugin's flux.
  • Elements are not frozen, as on a phone, so assigning element.type works.
  • An SVG import renders as SvgXml inside a component named after the file, such as Svg(star).
  • An image import equals assets.getIdByName for its name, and fakeAssetId('icon').

Settings Panel

SettingsPanel from @unbound-app/settings draws your generated panel as named nodes:

import { render } from '@unbound-app/api/testing';
import { SettingsPanel } from '@unbound-app/settings';
import { expect } from 'bun:test';

const view = render(<SettingsPanel />, { settings: { size: 22 } });

expect(view.findAll('NoticeRow')).toHaveLength(1);
expect(view.find('SliderRow').props.value).toBe(22);
NodeProps
Setting row (SwitchRow, SliderRow, ColorRow, ...)path, title, subtitle, disabled, value, onChange(value)
ButtonRow (setting.button)onPress() runs its action from src/actions.ts; confirm is the question asked first
NoticeRow (setting.notice)tone
Section (setting.section)title

Titles and subtitles are your messages in the active locale, and also the row's text. hidden draws nothing, visibleWhen and disabledWhen run on live settings, and the panel redraws when they change.

Switch the Language

setLocale changes the language for the rest of the test and redraws every component reading your messages.

import { render, setLocale } from '@unbound-app/api/testing';
import { expect, test } from 'bun:test';
import { messages } from '@unbound-app/i18n';
import { Text } from 'react-native';

function GreetingTitle() {
	return <Text>{messages.GREETING_TITLE}</Text>;
}

test('shows the title in German', () => {
	const view = render(<GreetingTitle />);

	setLocale('de');
	expect(view.text()).toBe('Willkommen zurück');
});

format uses real plural rules and number formats: in de, {count, plural, one {# Stern} other {# Sterne}} with 1200 is 1.200 Sterne. A missing key falls back to en-US.

Typed Options

render's settings and locale, and setLocale, accept anything until you add this file. Then an unknown setting, a wrong type, or an unshipped language is a type error.

tests/unbound-testing.d.ts
import type { Settings } from '@unbound-app/settings';

declare module '@unbound-app/api/testing' {
	interface PluginTypes {
		settings: Settings;
		locale: 'en-US' | 'de';
	}
}

Next