11 KiB
Testing
This document describes the test setup for the Project Browser Obsidian plugin.
Why it exists
The test setup enables automated verification of logic, migrations, and UI behaviour without manually exercising the plugin in Obsidian. Unit and component tests run quickly and can be executed in CI. E2E tests run the plugin inside real Obsidian to verify commands, views, and integration.
Overview
- Unit tests — Jest with jsdom, for utilities, migrations, and logic.
- Component tests — React Testing Library for React components (when added).
- E2E tests — WebdriverIO + wdio-obsidian-service, run plugin in Obsidian.
- Manual QA —
npm run open-qato build and open an isolated Obsidian instance with a test vault. - CI — GitHub Actions runs unit and E2E tests on push/PR to main.
Running tests
npm run test:unit # Run all unit tests (with coverage)
npm test # Same as test:unit
npm run test:unit:watch # Watch mode for development
npm run test:e2e # Build and run all E2E specs (latest + beta when available)
npm run test:e2e:spec -- tests/e2e/commands.e2e.ts # Run a single spec
npm run test:e2e:versions # Show which Obsidian version(s) will be tested
npm run test:e2e:latest # E2E against latest stable only
npm run test:e2e:beta # E2E against latest-beta only (requires Insiders credentials)
Test layout
- Colocated tests —
src/**/*.test.tsandsrc/**/*.test.tsxnext to the code they test. - Setup and mocks —
tests/setupTests.tsandtests/__mocks__/. - Component test helpers —
tests/helpers/component-test-utils.ts.
Mock layout
| File | Purpose |
|---|---|
tests/__mocks__/styleMock.js |
Empty object for CSS/SCSS imports |
tests/__mocks__/fileMock.js |
Stub for SVG and other assets |
tests/__mocks__/obsidianMock.js |
Stubs for Obsidian API (TFile, TFolder, Menu, Notice, etc.) |
tests/__mocks__/mainMock.js |
Minimal plugin stub for import from "src/main" |
The Jest moduleNameMapper routes obsidian, src/main, and asset imports to these mocks so tests run without the real Obsidian environment.
Component test pattern
For React components that use Jotai or call getGlobals():
import { render } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { setGlobalsForComponentTest, makePlugin } from 'tests/helpers/component-test-utils';
const wrap = (ui: React.ReactElement) =>
render(<JotaiProvider>{ui}</JotaiProvider>);
it('renders container', () => {
setGlobalsForComponentTest();
wrap(<SomeComponent plugin={makePlugin()} />);
expect(document.querySelector('.some-class')).toBeInTheDocument();
});
Use makePlugin(), makeTFile(), and makeTFolder() from tests/helpers/component-test-utils.ts for consistent test data.
Manual QA
npm run open-qa # Build, install into qa-test-vault, open isolated Obsidian
npm run open-qa-verbose # Same, with Obsidian verbose logging
Manual QA uses obsidian-launcher (like E2E tests) to launch a sandboxed Obsidian instance — separate from your system installation, so it opens only the QA vault and does not affect your normal vaults. The qa-test-vault/ directory is created and populated; the built plugin is installed automatically. The vault is in .gitignore.
CI
The .github/workflows/test.yaml workflow runs on push and pull requests to main/master:
- Checkout, Node 22,
npm ci npm run buildnpm run test:unit- Cache Obsidian (from version resolution)
- Setup virtual display (Xvfb + herbstluftwm on Ubuntu)
npm run test:e2e
Secrets OBSIDIAN_EMAIL and OBSIDIAN_PASSWORD (GitHub repository secrets) are recommended for stable, and required for beta. For local E2E, use .env or export these variables.
E2E test vault
E2E uses the generated vault at qa-test-vault/. The vault is created by qa-test-vault/generate.mjs at runtime — no vault content is committed. See qa-test-vault/README.md for generate and download-test-plugins usage.
E2E pipeline: npm run build → download-test-plugins.sh → generate.mjs → wdio run
E2E test layout
- Specs —
tests/e2e/**/*.e2e.ts - Helpers —
tests/e2e/helpers/dismiss-popups.ts(fallback for blocking notices),tests/e2e/helpers/open-card-browser.ts(openCardBrowserFrom,openCardBrowserAndEnterFolder),tests/e2e/helpers/project-browser-startup.ts(workspace reset + startup trigger helpers for reliability scenarios) - Vault —
qa-test-vault/(generated bygenerate.mjs, not committed)
E2E test coverage
| Spec | Covers |
|---|---|
| commands.e2e.ts | Plugin loaded; open-project-browser command; folder sections at root; navigate into folder and see state sections + note cards; cycle-state-forward / cycle-state-backward (active note state); toggle-state-menu (state menu visibility). Ribbon icon open is not asserted (manual QA). |
| navigation.e2e.ts | Back button returns to root; breadcrumb root click returns to root; opening a note card opens the note in the same leaf. |
| search.e2e.ts | Search button shows search input; typing filters cards; clear button hides search and clears filter. |
| new-tab-replacement.e2e.ts | Startup reliability coverage for all browser-entry paths: opening a new empty tab, clicking the ribbon button, closing all tabs including the final active tab, alternating entry methods, and recovering when the active leaf becomes empty without an active-leaf switch. Each scenario is repeated from multiple starting states. |
| settings.e2e.ts | Open app settings, open Project Browser tab, assert settings section (e.g. .ddc_pb_section) is present. |
| file-types.e2e.ts | File Types Test folder shows multiple file-type cards; Project A shows non-MD files (e.g. sample.png, sample.pdf) in the section. |
| context-menus-modals.e2e.ts | Folder context menu on right-click (expected items); file context menu on right-click on note card; Rename folder from menu opens modal; cancel leaves vault unchanged. |
All E2E specs use the same generated vault and call dismissBlockingPopups() in before. Startup-focused specs also reset the workspace between attempts so repeated runs do not inherit stale leaves from previous scenarios.
Vault folder order: Root folder buttons are sorted by name (ascending). In the generated vault the order is: Archive, File Types Test, Project A, Project B, Reference. Specs that need a project folder with state sections (e.g. search) use folder index 2 (Project A) or the openCardBrowserAndEnterFolderByName helper.
Coverage
Coverage is collected when you run npm run test:unit. Configuration is in jest.config.ts:
- collectCoverageFrom — Includes
src/**/*.{ts,tsx}and excludes*.test.{ts,tsx},*.d.ts, andsrc/main.tsso only library code is measured. - coverageThreshold — Global minimums (e.g. statements, branches, functions, lines at 20%) to catch regressions. Adjust as the suite grows.
- Output — V8 provider; report is written to
coverage/.
To run tests without coverage: npx jest --coverage=false.
Tested modules
Logic (unit): offset-state, get-file-type-label, get-file-display-name, get-state-and-priority-by-name, folder-processes, section-processes, frontmatter-processes, file-type-filter, file-processes, file-access-processes, is-extension-unsupported, toggle-state-menu, stores.
Utils (unit): string-processes, string-processes-extra, sorting, file-manipulation, misc, storage.
Types (unit): plugin-settings-migrations, migration-helpers, migration tests (0.0.4→0.0.5, 0.0.5→0.1.0, 0.1.0→0.3.0).
Commands (unit): open-project-browser, reveal-in-project-browser (native menu registration, section detection from menu items, section caching by source, null-target skip, alternative Finder titles, no-Finder fallback).
Logic — reveal (unit): reveal-in-project-browser (reveal location for files and folders, multi-select target resolution, empty selection, mixed-parent selection, file without parent folder, existing-leaf reuse, new-leaf creation, concurrent-reveal guard).
Context menus (unit): project-context-menu (priority items, reveal action).
Modals (unit): ConfirmationModal (constructor options).
Components (React): Tooltip, SearchInput, BackButtonAndPath. Others (e.g. card-browser, state-section, modals with heavy Obsidian coupling) are deferred; E2E and manual QA cover integration.
Technical details
- Jest —
jest-environment-jsdomfor DOM/React support. - Babel —
@babel/preset-reactwith automatic runtime for JSX. - Coverage — V8 provider, output in
coverage/. - E2E — WebdriverIO 9, Mocha, wdio-obsidian-service. Config:
wdio.conf.mts. TypeScript:tsconfig.e2e.json. - Startup E2E strategy — Browser startup tests intentionally cover both event-driven entry (
active-leaf-change) and layout-driven recovery (layout-change) because empty-leaf transitions do not always come from opening a fresh tab. - Reveal menu section detection — The
file-menu/files-menuhandler inspects theMenuobject's internalitemsarray to find the built-in Finder item and read itssection. This is not a public Obsidian API; if internal menu structure changes, the reveal item will still appear but may land in a different section. The detected section is cached per menusourcestring.
Technical gotchas
getGlobals()— Components that callgetGlobals()requiresetGlobalsForComponentTest()(or equivalent) before render, or they will throw. Logic tests that usegetGlobals()should mocksrc/logic/storeswithjest.mock('./stores', () => ({ getGlobals: jest.fn() }))and set the return value in each test.- Obsidian types — Tests use the mocked
TFile/TFolderfromobsidianMock.js. The mock constructors match the real API (TFile(name, ctime, mtime),TFolder(name)). Addvault,path, orparenton the instance in tests when the code under test expects them. - Path aliases —
src/imports resolve via JestmoduleNameMapperandmodulePaths; ensure^src/main$is mapped before the generic^src/(.*)$pattern. - Stores / Jotai — Tests for
storesortoggle-state-menuuse the real Jotai default store; callsetGlobals()inbeforeEachso dependent code does not throw. Fake timers are used in toggle-state-menu tests forreturnStateMenuAfterDelay. - Obsidian Modal mock — The global
obsidianMock.jsModal returns a plain object, so subclasses like ConfirmationModal may not have their prototype methods (e.g.onOpen) on the returned instance. Prefer testing constructor-assigned options or mock Modal per test when needed. - Closing all tabs is not the same as creating an empty leaf — For startup reliability tests, make sure the final active tab is actually closed last. That produces a different workspace transition than simply calling
getLeaf(true)and is the path that exercises “browser opens after all tabs are gone”.