obsidian-integration-testing
v1.1.2
Published
Simplifies integration testing of Obsidian plugins.
Maintainers
Readme
obsidian-integration-testing
A set of helpers that simplify integration testing of Obsidian plugins against a running Obsidian instance via the Obsidian CLI.
Prerequisites
- Obsidian should have CLI enabled.
Installation
npm install --save-dev obsidian-integration-testingUsage
Plugin Vitest setup
This entry point is designed for Obsidian plugin repos only. It expects your built plugin in
dist/devordist/build(whichever has a newermain.js), with amanifest.jsonat the root of the chosen folder. The setup creates a temporary vault, copies the build into it, and enables the plugin via the Obsidian CLI.
Add it as a Vitest global setup:
// vitest.config.ts
export default defineConfig({
test: {
globalSetup: ['obsidian-integration-testing/obsidian-plugin-vitest-setup']
}
});Write integration tests
Use evalInObsidian() to run code inside the Obsidian process. The vaultPath is optional — it defaults to process.cwd():
import { evalInObsidian } from 'obsidian-integration-testing';
// Simple expression
const sum = await evalInObsidian({
args: { a: 2, b: 3 },
fn: ({ a, b }) => a + b
});
// sum === 5Access the Obsidian API
Every callback receives app (the Obsidian App instance) and obsidianModule (the full obsidian module):
// Read the vault config directory
const configDir = await evalInObsidian({
fn: ({ app }) => app.vault.configDir
});
// Use the obsidian module
const yaml = await evalInObsidian({
fn: ({ obsidianModule }) => obsidianModule.stringifyYaml({ key: 'value' })
});
// Access internal APIs
const title = await evalInObsidian({
fn: ({ app }) => app.title
});Pass complex arguments
Arguments are JSON-serialized. You can even pass functions — they are serialized via toString():
const result = await evalInObsidian({
args: {
transform(x: number): number {
return x * 2;
},
value: 5
},
fn: ({ transform, value }) => transform(value)
});
// result === 10Persist non-serializable values across calls
Obsidian objects like TFile or Editor live in the Obsidian process and can't be returned to the test. Use ContextId to create a typed store that persists across calls:
import type { TFile } from 'obsidian';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ContextId, evalInObsidian } from 'obsidian-integration-testing';
interface Context {
file: TFile;
}
const contextId = new ContextId<Context>();
beforeEach(async () => {
await evalInObsidian({
contextId,
fn: async ({ app, context }) => {
context.file = await app.vault.create('test.md', '# Hello');
}
});
});
afterEach(async () => {
await evalInObsidian({
contextId,
fn: async ({ app, context: { file } }) => {
await app.vault.delete(file);
}
});
await contextId.dispose();
});
it('should read the file path', async () => {
const path = await evalInObsidian({
contextId,
fn: ({ context: { file } }) => file.path
});
expect(path).toBe('test.md');
});Create a temporary vault
Use TempVault to create a disposable vault pre-populated with files:
import type { TFile } from 'obsidian';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { ContextId, evalInObsidian, TempVault } from 'obsidian-integration-testing';
interface Context {
file: TFile;
}
const vault = new TempVault();
vault.populate({
'note.md': '# Hello',
'folder/nested.md': 'nested content',
});
const contextId = new ContextId<Context>();
beforeAll(async () => {
await vault.register();
// Resolve the pre-populated file into a TFile and store it in the context
await evalInObsidian({
contextId,
fn: async ({ app, context }) => {
const file = app.vault.getFileByPath('note.md');
if (!file) {
throw new Error('File not found');
}
context.file = file;
},
vaultPath: vault.path
});
});
afterAll(async () => {
await contextId.dispose(vault.path);
await vault.dispose();
});
it('should read a pre-populated file', async () => {
const content = await evalInObsidian({
fn: ({ app }) => app.vault.adapter.read('note.md'),
vaultPath: vault.path
});
expect(content).toBe('# Hello');
});
it('should access the TFile from context', async () => {
const path = await evalInObsidian({
contextId,
fn: ({ context: { file } }) => file.path,
vaultPath: vault.path
});
expect(path).toBe('note.md');
});Both TempVault and ContextId implement AsyncDisposable, so you can use await using for automatic cleanup.
Parent directories are created automatically. To create an empty folder, use a path ending with / and an empty string as content.
Test your plugin
Use getTempVault() to get the temporary vault created by the global setup:
import { describe, expect, it } from 'vitest';
import { evalInObsidian } from 'obsidian-integration-testing';
import { getTempVault } from 'obsidian-integration-testing/obsidian-plugin-vitest-setup';
describe('my-plugin', () => {
const vault = getTempVault();
it('should be enabled', async () => {
const isEnabled = await evalInObsidian({
args: { pluginId: 'my-plugin' },
fn: ({ app, pluginId }) => app.plugins.enabledPlugins.has(pluginId),
vaultPath: vault.path
});
expect(isEnabled).toBe(true);
});
it('should create a file', async () => {
await evalInObsidian({
fn: async ({ app }) => {
await app.vault.create('test.md', '# Hello');
},
vaultPath: vault.path
});
const content = await evalInObsidian({
fn: ({ app }) => app.vault.adapter.read('test.md'),
vaultPath: vault.path
});
expect(content).toBe('# Hello');
});
});[!WARNING]
Parallelism:
The Obsidian CLI does not support executing multiple commands concurrently. If your test runner launches tests in parallel, CLI calls may collide and produce flaky failures. Disable file-level parallelism in your Vitest config:
// vitest.config.ts export default defineConfig({ test: { fileParallelism: false } });
[!WARNING]
evalInObsidianlimitations:
- The function is serialized via
toString()and executed in a separate process. It must be self-contained — closures over local variables will not work.- Pass any needed values via
args. Arguments must be JSON-serializable (strings, numbers, booleans, arrays, plain objects). Functions inargsare supported — they are serialized viatoString()with the same self-contained constraint.- The return value must also be JSON-serializable. You cannot return functions, class instances,
Map,Set, DOM elements, or other non-serializable values.- Imports (
import/require) are not available inside the function. UseobsidianModuleto access theobsidianAPI, andappto access the ObsidianAppinstance.
Accessing internal APIs
Since evalInObsidian runs inside a real Obsidian process, you have access to internal (undocumented) APIs like app.plugins, app.commands, app.title, etc. However, these are not declared in obsidian.d.ts, so TypeScript won't compile references to them. Here are the options to make it work, from best to worst:
1. Use obsidian-typings (recommended) — install obsidian-typings which declares the full internal API. Everything compiles with no extra work:
// With obsidian-typings installed — no casts needed
const title = await evalInObsidian({
fn: ({ app }) => app.title
});2. Manual module augmentation — declare only what you need:
declare module 'obsidian' {
interface App {
title: string;
}
}
const title = await evalInObsidian({
fn: ({ app }) => app.title
});3. as any / @ts-expect-error / @ts-ignore (not recommended) — suppresses all type checking and hides real errors:
const title = await evalInObsidian({
// @ts-expect-error -- accessing internal API
fn: ({ app }) => app.title
});
// or
const title2 = await evalInObsidian({
fn: ({ app }) => (app as any).title
});Support
My other Obsidian resources
See my other Obsidian resources.
