obsidian-test-mocks
v3.0.0
Published
Comprehensive test mocks for the Obsidian API.
Downloads
1,026
Maintainers
Readme
obsidian-test-mocks
Comprehensive test mocks for the Obsidian plugin API. Provides in-memory implementations of every class and function in obsidian.d.ts, plus prototype extensions Obsidian adds to DOM/JS builtins. The package is tested with 100% code coverage (lines, branches, functions, and statements) enforced on every build.
Installation
npm install --save-dev obsidian-test-mocksPeer dependencies: obsidian
Entry Points
| Import path | Description |
| ---------------------------------------------------- | ------------------------------------------------------------------------- |
| obsidian-test-mocks/obsidian | Mocks for every class/function in obsidian.d.ts |
| obsidian-test-mocks/setup | Exports setup() / teardown() for prototype extensions and globals |
| obsidian-test-mocks/vitest-setup | One-stop Vitest setup file: calls setup() + mocks the obsidian module |
| obsidian-test-mocks/jest-setup | Jest setup file: calls setup() for prototype extensions and globals |
| obsidian-test-mocks/obsidian-typings/setup | Exports setup() / teardown() for obsidian-typings bridges |
| obsidian-test-mocks/obsidian-typings/vitest-setup | Vitest setup file: auto-calls obsidian-typings bridge setup() |
| obsidian-test-mocks/obsidian-typings/jest-setup | Jest setup file: auto-calls obsidian-typings bridge setup() |
Usage with Vitest
Add the Vitest setup file — it patches prototypes/globals and mocks obsidian automatically:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
server: {
deps: {
inline: ['@obsidian-typings', 'obsidian-dev-utils']
}
},
setupFiles: ['obsidian-test-mocks/vitest-setup'],
},
});[!NOTE] The
server.deps.inlinesetting tells Vitest to bundle@obsidian-typingsandobsidian-dev-utilsinto the test transform pipeline instead of treating them as external Node.js imports. Without this, Vitest may fail to resolve these transitive dependencies at runtime. Add any other packages that causeCannot find moduleerrors during test setup to this list.
Usage with Jest
Add the Jest setup file and moduleNameMapper to alias the obsidian module:
module.exports = {
moduleNameMapper: {
'^obsidian$': 'obsidian-test-mocks/obsidian',
},
setupFiles: ['obsidian-test-mocks/jest-setup'],
};[!NOTE] Unlike Vitest, Jest requires
moduleNameMapperbecause theobsidiannpm package is types-only (no JS runtime) andjest.mockin setup files cannot resolve it.
Usage with Other Frameworks
The vitest-setup and jest-setup entry points already handle prototype/global patching and obsidian module aliasing. If you use a different test framework, you need to do both manually using the generic setup entry point:
Prototype/global patching — call
setup()/teardown()in your lifecycle hooks:import { setup, teardown } from 'obsidian-test-mocks/setup'; beforeAll(() => setup()); afterAll(() => teardown());Module aliasing — redirect
import ... from 'obsidian'to the mocks so that your production code under test receives mock implementations. For reference, here is whatvitest-setupdoes:vi.mock('obsidian', async () => await import('obsidian-test-mocks/obsidian'));Write something similar using your framework's module mocking API, or configure module resolution at the config level (as shown in the Jest example with
moduleNameMapper).
[!WARNING]
If your test framework does not support module mocking or aliasing, it cannot be used with this library.
Production code under test does
import { ... } from 'obsidian', and without module aliasing those imports will not resolve to the mocks.
Importing in Test Files
Module aliasing (via vi.mock, moduleNameMapper, etc.) redirects import ... from 'obsidian' to the mocks at runtime, but TypeScript still resolves types from obsidian.d.ts at compile time. This means mock-only members like create__(), asOriginalType__(), and simulateClick__() are not visible when importing from 'obsidian'.
To access mock-specific APIs, import directly from 'obsidian-test-mocks/obsidian' in your test files:
// Test file — gets mock types with create__(), asOriginalType__(), etc.
import { App } from 'obsidian-test-mocks/obsidian';
const app = App.createConfigured__();Use import type ... from 'obsidian' when you need the original obsidian type (e.g., for function parameter annotations):
import type { App as AppOriginal } from 'obsidian';
import { App } from 'obsidian-test-mocks/obsidian';
const app = App.createConfigured__();
function pluginInit(app: AppOriginal): void { /* ... */ }
pluginInit(app.asOriginalType__());[!IMPORTANT]
The
vi.mock/moduleNameMapperaliasing is still required — it ensures your production code under test (which doesimport { ... } from 'obsidian') receives mock implementations at runtime. But in test files where you callcreate__()and other__members, import from'obsidian-test-mocks/obsidian'so TypeScript can see those members.
Creating Mock Instances
Classes whose constructors are not public in obsidian.d.ts expose a static create__() factory method:
import { App } from 'obsidian-test-mocks/obsidian';
const app = App.create__();The __ suffix signals this member is not part of the real Obsidian API — it exists only in the mocks for testing purposes. This convention applies to all mock-only public members: factory methods (create__()), type-bridge helpers (asOriginalType__()), and test helpers (simulateClick__(), simulateChange__()).
Spying on Instance Creation
The create__() pattern makes all instance creation spyable:
import { vi } from 'vitest';
import { WorkspaceLeaf } from 'obsidian-test-mocks/obsidian';
const spy = vi.spyOn(WorkspaceLeaf, 'create2__');
// ... code that creates leaves ...
expect(spy).toHaveBeenCalledTimes(2);Pre-configured App
Use App.createConfigured__() for a fully wired App instance. Parent folders are created automatically from file paths:
import { App } from 'obsidian-test-mocks/obsidian';
const app = App.createConfigured__({
files: {
'notes/daily/2024-01-01.md': '# New Year',
},
});
// folders "notes" and "notes/daily" are created automaticallyPaths ending with / are treated as folders (content must be empty):
const app = App.createConfigured__({
files: {
'archive/2023/': '',
},
});Strict Mocks
Every mock instance is wrapped in a Proxy that throws a descriptive error when you access a property that isn't implemented, instead of silently returning undefined:
Property "internalPlugins" is not mocked in App. To override, assign a value first: mock.internalPlugins = ...Overriding Behavior
The strict proxy is fully override-friendly. Assign a value and subsequent reads just work:
// Spy on an existing method
vi.spyOn(app.vault, 'read').mockResolvedValue('custom content');
// Batch-extend with Object.assign
Object.assign(app, { commands: { addCommand: vi.fn() } });Accessing Unimplemented Properties
Properties not implemented in the mock (such as app.internalPlugins) will throw at runtime:
Property "internalPlugins" is not mocked in App. To override, assign a value first: mock.internalPlugins = ...You can add them by assigning a value first:
app.internalPlugins = { manifests: {} };But since internalPlugins is not declared in obsidian.d.ts, TypeScript won't compile that assignment. 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. The assignment compiles with no extra work.
2. Manual module augmentation (recommended) — declare only what you need:
declare module 'obsidian' {
interface App {
internalPlugins: { manifests: Record<string, unknown> };
}
}
app.internalPlugins = { manifests: {} };3. Cast to Record<string, unknown> (less recommended) — quick one-off escape hatch, still catches typos in the value:
(app as Record<string, unknown>).internalPlugins = { manifests: {} };4. as any / @ts-expect-error / @ts-ignore (not recommended) — suppresses all type checking and hides real errors:
(app as any).internalPlugins = { manifests: {} };
// @ts-expect-error -- accessing internal API
app.internalPlugins = { manifests: {} };Type Bridging with asOriginalType__()
Mock types and original obsidian types are structurally different — you cannot assign a mock App to a parameter typed as import('obsidian').App. Every mock class provides an asOriginalType__() method that returns the instance typed as its original obsidian counterpart:
import type { App as AppOriginal } from 'obsidian';
import { App } from 'obsidian-test-mocks/obsidian';
const app = App.createConfigured__();
// Pass to code that expects the original obsidian type
function pluginInit(app: AppOriginal): void { /* ... */ }
pluginInit(app.asOriginalType__());This is a zero-cost type cast at runtime — no wrapping, no cloning. The __ suffix signals it is not part of the real Obsidian API.
Subclasses use numbered variants following inheritance depth: asOriginalType__() on the root class, asOriginalType2__() on its child, asOriginalType3__() on the grandchild, etc. The inherited base method remains callable at any level. For example, Vault (which extends Events) uses asOriginalType2__().
Reverse Bridging with fromOriginalType__()
The inverse of asOriginalType__(). Every mock class provides a static fromOriginalType__() method that accepts a real-typed obsidian object and returns it typed as the mock class:
import type { App as AppOriginal } from 'obsidian';
import { App, Vault } from 'obsidian-test-mocks/obsidian';
const app: AppOriginal = App.createConfigured__().asOriginalType__();
// Convert back to mock type when you need mock-specific APIs
const mockVault = Vault.fromOriginalType2__(app.vault);
mockVault.setVaultAbstractFile__('path', file);This eliminates the need for maintaining dual variables (mockApp / app). Keep the real App type throughout your test and convert to the mock type only when calling mock-specific APIs.
Subclasses use numbered variants following the same inheritance-depth convention as asOriginalType__(): fromOriginalType__() on the root, fromOriginalType2__() on its child, etc.
Because every mock is a strict mock, passing the result to code that accesses internal members (not part of obsidian.d.ts) will throw a descriptive error unless you assign those members first:
const app = App.createConfigured__();
const original = app.asOriginalType__();
// If pluginInit() accesses app.internalPlugins internally, this throws:
// Property "internalPlugins" is not mocked in App.
// To override, assign a value first: mock.internalPlugins = ...
pluginInit(original);
// Fix: assign the missing member before calling
(app as Record<string, unknown>)['internalPlugins'] = { manifests: {} };
pluginInit(original); // worksOverriding Exported Variables
Some exports like apiVersion are plain strings, not functions. Since ES module bindings are read-only for consumers, use vi.mock() to override them:
import { vi } from 'vitest';
vi.mock('obsidian', async (importOriginal) => ({
...(await importOriginal<typeof import('obsidian')>()),
apiVersion: '1.8.0',
}));
import { apiVersion } from 'obsidian';
it('uses the overridden apiVersion', () => {
expect(apiVersion).toBe('1.8.0');
});Using with obsidian-typings
This package does not have a runtime dependency on obsidian-typings, but it works seamlessly if your project uses it.
obsidian-typings uses declare module 'obsidian' to augment obsidian types with dozens of internal properties (e.g., App.internalPlugins, App.commands). This makes import('obsidian').App a superset of what obsidian.d.ts alone declares. The mock types only implement the public API from obsidian.d.ts, so the two are structurally incompatible.
Automatic bridging with obsidian-typings entry points
The obsidian-test-mocks/obsidian-typings/* entry points automatically bridge commonly used obsidian-typings internal properties to their mock counterparts. Add the appropriate setup file to your test runner after the main setup:
Vitest:
// vitest.config.ts
export default defineConfig({
test: {
setupFiles: [
'obsidian-test-mocks/vitest-setup',
'obsidian-test-mocks/obsidian-typings/vitest-setup',
],
},
});Jest:
module.exports = {
moduleNameMapper: {
'^obsidian$': 'obsidian-test-mocks/obsidian',
},
setupFiles: [
'obsidian-test-mocks/jest-setup',
'obsidian-test-mocks/obsidian-typings/jest-setup',
],
};Other frameworks — use the generic setup entry point:
import { setup, teardown } from 'obsidian-test-mocks/obsidian-typings/setup';
beforeAll(() => setup());
afterAll(() => teardown());This bridges the following properties:
| Class | obsidian-typings property | Mock property |
| ------------------- | -------------------------------------- | -------------------------------------- |
| CapacitorAdapter | insensitive | insensitive__ |
| Component | _loaded | loaded__ |
| Component | _children | children__ |
| FileSystemAdapter | insensitive | insensitive__ |
| SettingGroup | listEl | listEl__ |
| TAbstractFile | deleted | deleted__ |
| Vault | exists | (delegates to adapter/file map) |
| Vault | getAbstractFileByPathInsensitive | getAbstractFileByPathInsensitive__() |
| Vault | getAvailablePath | (stub: returns basePath.extension) |
After setup, code using obsidian-typings property names works transparently through the strict proxy:
const component = Component.create__();
component.load();
// With obsidian-typings/vitest-setup, this works instead of throwing:
console.log(component._loaded); // trueThe entry point also exports teardown() to remove all bridges.
Manual property assignment
For properties not covered by the automatic bridging, use asOriginalType__() to bridge the gap when passing mocks to code that expects obsidian types:
import type { App as AppOriginal } from 'obsidian';
import { App } from 'obsidian-test-mocks/obsidian';
function myPluginHelper(app: AppOriginal): void { /* ... */ }
const app = App.createConfigured__();
myPluginHelper(app.asOriginalType__());With obsidian-typings installed, the returned type includes all augmented properties, so you can assign internal members in a type-safe way:
const app = App.createConfigured__();
const original = app.asOriginalType__();
// Type-safe with obsidian-typings — no casts needed
original.internalPlugins = { manifests: {} };Without obsidian-typings, you can still assign them via a Record cast:
const app = App.createConfigured__();
(app as unknown as Record<string, unknown>)['internalPlugins'] = { manifests: {} };Remember that accessing any property not assigned on the mock (and not covered by the automatic bridging) will throw a strict mock error at runtime, regardless of whether obsidian-typings makes it compile.
Design Principles
- Only
obsidian.d.ts— core mocks expose exactly the public API; optionalobsidian-typings/*entry points bridgeobsidian-typingsinternals - Meaningful implementations — real in-memory behavior (state tracking, callbacks, data storage), not empty stubs
- Spyable — all instance creation routes through
create__()sovi.spyOn()works everywhere - No
obsidian-typingsruntime dependency — type shapes are inlined to avoid global module augmentation side effects;obsidian-typingsis a dev dependency used only for bridge type validation
Support
My other Obsidian resources
See my other Obsidian resources.
