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:
[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.
| Import | What a test gets |
|---|---|
@unbound-app/i18n | Your real i18n/*.json, with an ICU formatter. en-US until you switch |
@unbound-app/settings | Your settings.ts with its defaults, in an in-memory bucket |
@unbound-app/api and every subpath | Fakes that record patches, commands, toasts, dialogs, Flux and assets. Also fakes metro, native, plugins, ui, utils and fs |
react-native | Each 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?asset | A 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
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.
| Export | What 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.shown | Each toast's options. A shorthand such as toasts.success('SAVED', vars) also records key and vars, with title in the active locale |
dialogs.opened | Each 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.applied | Each 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.entries | Each line the plugin's logger wrote |
metro.register(module, path?) | Gives the plugin a Discord module to find |
native | The fake bridge. See Testing Native Code |
plugins, fs | Other 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.addChangeListenerFix 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:
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.
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.
| Namespace | Fake |
|---|---|
metro.api | Messages, Linking, Profiles, AsyncUsers, Invites, OAuth2, ActionSheets, UserProfiles, found once a module with their props registers |
metro.common | The same, such as Theme, Clipboard, Moment. React and ReactNative are the fakes |
metro.stores | Accessibility, Theme, Users, Guilds, seeded with flux.seed |
metro.components | Any component by name. See below |
metro.filters | byProps, byName, byDisplayName, byTypeName, byFilePath, all, any, not |
utils, utils.react | The 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.Buttondraws itstext;RowButtonandTableRowdrawlabelandsubLabel.Discord.createStyles(styles)returns a hook givingstyles, calling a factory with no theme.- Replace one by assignment:
Discord.Text = MyText. @unbound-app/api/uirows such asSwitchRow,PageandSchemaPanelwork 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:
| Option | Sets |
|---|---|
settings | Some settings, sections in part. The rest keep defaults |
locale | The language @unbound-app/i18n reads |
stores | Flux stores by name, with or without Store, read by flux.getStore and flux.useStore |
| Returns | Does |
|---|---|
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.
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>
);
}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
useSettingvalue, or a store it reads changes. - To change a store mid-test, call
flux.seedagain. Import it asflux as fakeFluxbeside your plugin'sflux. - Elements are not frozen, as on a phone, so assigning
element.typeworks. - An SVG import renders as
SvgXmlinside a component named after the file, such asSvg(star). - An image import equals
assets.getIdByNamefor its name, andfakeAssetId('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);| Node | Props |
|---|---|
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.
import type { Settings } from '@unbound-app/settings';
declare module '@unbound-app/api/testing' {
interface PluginTypes {
settings: Settings;
locale: 'en-US' | 'de';
}
}