https://github.com/mnaoumov/obsidian-integration-testing
Simplifies integration testing of Obsidian plugins.
https://github.com/mnaoumov/obsidian-integration-testing
integration-testing obsidian obsidian-md
Last synced: about 1 month ago
JSON representation
Simplifies integration testing of Obsidian plugins.
- Host: GitHub
- URL: https://github.com/mnaoumov/obsidian-integration-testing
- Owner: mnaoumov
- License: mit
- Created: 2026-03-23T05:04:46.000Z (3 months ago)
- Default Branch: master
- Last Pushed: 2026-05-04T03:23:37.000Z (about 1 month ago)
- Last Synced: 2026-05-04T04:17:37.770Z (about 1 month ago)
- Topics: integration-testing, obsidian, obsidian-md
- Language: TypeScript
- Homepage:
- Size: 6.51 MB
- Stars: 2
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Funding: .github/FUNDING.yml
- License: LICENSE
Awesome Lists containing this project
README
# obsidian-integration-testing
[](https://www.buymeacoffee.com/mnaoumov)
[](https://www.npmjs.com/package/obsidian-integration-testing)
[](https://www.npmjs.com/package/obsidian-integration-testing)
[](https://github.com/mnaoumov/obsidian-integration-testing/releases)
[](https://github.com/mnaoumov/obsidian-integration-testing)
A set of helpers that simplify integration testing of [Obsidian](https://obsidian.md/) plugins against a running Obsidian instance.
## Installation
```bash
npm install --save-dev obsidian-integration-testing
```
## Quick start
The global setup expects your built plugin in `dist/dev` or `dist/build` (whichever has a newer `main.js`), with a `manifest.json` at the root of the chosen folder. The setup creates a temporary vault, copies the build into it, and enables the plugin.
### Vitest
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globalSetup: ['obsidian-integration-testing/vitest-global-setup'],
},
});
```
To get Vitest module augmentations (`environmentOptions.obsidianTransport`, `inject('obsidianTransport')`, `inject('tempVaultPath')`), add a side-effect import in your test setup or config:
```ts
import 'obsidian-integration-testing/vitest/typings';
```
Or add it to `compilerOptions.types` in your `tsconfig.json`:
```json
{
"compilerOptions": {
"types": ["obsidian-integration-testing/vitest/typings"]
}
}
```
### Jest
```ts
// jest.config.ts
export default {
globalSetup: 'obsidian-integration-testing/jest-global-setup',
};
```
To configure transport options with Jest, populate `globalThis.__obsidianIntegrationTesting` before the global setup runs (e.g., in a setup file or via Jest `globals`):
```ts
globalThis.__obsidianIntegrationTesting = {
transportOptions: { type: 'obsidian-cdp' },
};
```
After setup, `globalThis.__obsidianIntegrationTesting.tempVaultPath` is available in test workers.
By default this uses the **`CLI` transport** (requires `CLI` enabled in Obsidian settings). See [Transport modes](#transport-modes) for alternatives.
### Write integration tests
Use `evalInObsidian()` to run code inside the Obsidian process. The `vaultPath` is optional — it defaults to `process.cwd()`:
```ts
import { evalInObsidian } from 'obsidian-integration-testing';
// Simple expression
const sum = await evalInObsidian({
args: { a: 2, b: 3 },
fn: ({ a, b }) => a + b
});
// sum === 5
```
### Access the Obsidian API
Every callback receives `app` (the Obsidian `App` instance) and `obsidianModule` (the full `obsidian` module):
```ts
// 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()`:
```ts
const result = await evalInObsidian({
args: {
transform(x: number): number {
return x * 2;
},
value: 5
},
fn: ({ transform, value }) => transform(value)
});
// result === 10
```
### Persist 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:
```ts
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();
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:
```ts
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();
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:
**Vitest:**
```ts
import { describe, expect, it } from 'vitest';
import { evalInObsidian } from 'obsidian-integration-testing';
import { getTempVault } from 'obsidian-integration-testing/vitest-global-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');
});
});
```
**Jest:**
```ts
import { evalInObsidian } from 'obsidian-integration-testing';
import { getTempVault } from 'obsidian-integration-testing/jest-global-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);
});
});
```
> [!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:
>
> ```ts
> // vitest.config.ts
> export default defineConfig({
> test: {
> fileParallelism: false
> }
> });
> ```
> [!WARNING]
>
> **`evalInObsidian` limitations:**
>
> - 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 in `args` are supported — they are serialized via `toString()` 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. Use `obsidianModule` to access the `obsidian` API, and `app` to access the Obsidian `App` instance.
### 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`](https://www.npmjs.com/package/obsidian-typings) which declares the full internal API. Everything compiles with no extra work:
```ts
// With obsidian-typings installed — no casts needed
const title = await evalInObsidian({
fn: ({ app }) => app.title
});
```
**2. Manual module augmentation** — declare only what you need:
```ts
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:
```ts
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
});
```
### Transport modes
The transport determines how the library communicates with Obsidian. Configure it via transport options in your test framework's config (see [Quick start](#quick-start)):
| Type | Platform | Mechanism |
|----------------------------|----------|-------------------------------------------------------------|
| `obsidian-cli` (default) | Desktop | Obsidian `Command Line Interface` (`CLI`) (`obsidian eval`) |
| `obsidian-cdp` | Desktop | Obsidian `Chrome DevTools Protocol` (`CDP`) |
| `obsidian-android-appium` | Mobile | Obsidian Android Appium WebView JS injection |
#### `CLI` transport (default)
Shells out to the Obsidian `Command Line Interface` (`CLI`) binary for each eval call. This is the default when no `obsidianTransport` is configured.
**Setup:**
1. [Install the Obsidian `CLI`](https://obsidian.md/help/cli#Install+Obsidian+CLI).
2. Enable `CLI` in Obsidian: `Settings → General → Developer tools → Enable CLI`.
No additional vitest configuration needed — `CLI` is the default transport.
#### `CDP` transport
Connects via WebSocket to Obsidian `Chrome DevTools Protocol` (`CDP`) endpoint. No `CLI` binary needed, no `CLI enabled` setting required, and lower overhead per eval.
**Setup:**
1. Launch Obsidian with the `--remote-debugging-port` flag:
```powershell
# Windows (PowerShell)
& "$env:LOCALAPPDATA\Programs\Obsidian\Obsidian.exe" --remote-debugging-port=8315
```
```cmd
# Windows (CMD)
"%LOCALAPPDATA%\Programs\Obsidian\Obsidian.exe" --remote-debugging-port=8315
```
```bash
# macOS
/Applications/Obsidian.app/Contents/MacOS/Obsidian --remote-debugging-port=8315
# Linux
obsidian --remote-debugging-port=8315
```
2. Ensure [`Node.js`](https://nodejs.org/) 22+ is installed (uses built-in `WebSocket` and `fetch` globals).
3. Configure vitest:
```ts
// vitest.config.ts
export default defineConfig({
test: {
fileParallelism: false,
globalSetup: ['obsidian-integration-testing/vitest-global-setup'],
environmentOptions: {
obsidianTransport: { type: 'obsidian-cdp' },
},
},
});
```
Optional configuration:
```ts
environmentOptions: {
obsidianTransport: {
type: 'obsidian-cdp',
// default values can be omitted
host: 'localhost',
port: 8315,
commandTimeoutInMilliseconds: 30000
},
}
```
#### Obsidian Android Appium transport
Runs tests against Obsidian Mobile on an Android emulator or real device via Appium WebView injection.
**Setup:**
1. Install [Android Studio](https://developer.android.com/studio), which includes the Android SDK and `adb` command-line tools
2. Set up a device — either an emulator or a real device:
**Emulator:**
- Open Android Studio → Device Manager → Create Virtual Device
- Select a phone profile (e.g. Pixel 7) and a system image (e.g. API 34)
- Start the emulator
**Real device:**
- Enable Developer Options: Settings → About phone → tap "Build number" 7 times
- Enable USB Debugging: Settings → Developer options → USB debugging
- Connect via USB and accept the debugging prompt on the device
- Verify the device is detected:
```bash
adb devices
```
3. Install [Obsidian](https://obsidian.md/download) on the device (via Play Store or APK sideload) and grant storage permission — either via the app's permission prompt or via `adb`:
```bash
adb shell appops set md.obsidian MANAGE_EXTERNAL_STORAGE allow
```
4. Install [Appium](https://appium.io/) and the [UiAutomator2 driver](https://github.com/appium/appium-uiautomator2-driver):
```bash
npm install -g appium
appium driver install uiautomator2
```
5. Start the Appium server:
```bash
appium
```
6. Find the device ID (use the value from the `adb devices` output):
```bash
adb devices
# Example output:
# emulator-5554 device
# R5CR1234567 device ← real device
```
7. Configure vitest:
```ts
// vitest.config.ts
export default defineConfig({
test: {
fileParallelism: false,
globalSetup: ['obsidian-integration-testing/vitest-global-setup'],
environmentOptions: {
obsidianTransport: {
type: 'obsidian-android-appium',
appiumUrl: 'http://localhost:4723',
deviceId: 'emulator-5554',
},
},
},
});
```
> [!NOTE]
>
> Plugins with `isDesktopOnly: true` in `manifest.json` automatically reject Android tests.
### Running multiple platforms
Use vitest projects to run the same tests on multiple platforms:
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: [
{
test: {
name: 'integration-tests:desktop-cli',
fileParallelism: false,
globalSetup: ['obsidian-integration-testing/vitest-global-setup'],
include: ['src/**/*.integration.test.ts'],
exclude: ['src/**/*.android.integration.test.ts'],
// default, can be omitted
environmentOptions: {
obsidianTransport: { type: 'obsidian-cdp' },
},
},
},
{
test: {
name: 'integration-tests:desktop-cdp',
fileParallelism: false,
globalSetup: ['obsidian-integration-testing/vitest-global-setup'],
include: ['src/**/*.integration.test.ts'],
exclude: ['src/**/*.android.integration.test.ts'],
environmentOptions: {
obsidianTransport: { type: 'obsidian-cdp' },
},
},
},
{
test: {
name: 'integration-tests:android-appium',
fileParallelism: false,
globalSetup: ['obsidian-integration-testing/vitest-global-setup'],
include: ['src/**/*.android.integration.test.ts'],
environmentOptions: {
obsidianTransport: {
type: 'obsidian-android-appium',
appiumUrl: 'http://localhost:4723',
deviceId: 'emulator-5554',
},
},
},
},
],
},
});
```
Run specific platforms:
```bash
# All tests
npx vitest run
# Desktop CLI only
npx vitest run --project integration-tests:desktop-cli
# Desktop CDP only
npx vitest run --project integration-tests:desktop-cdp
# Android only (requires Appium + emulator running)
npx vitest run --project integration-tests:android-appium
# All platforms
npx vitest run --project integration-tests:*
```
## Support
## My other Obsidian resources
[See my other Obsidian resources](https://github.com/mnaoumov/obsidian-resources).
## License
© [Michael Naumov](https://github.com/mnaoumov/)
