Figma Plugin QA
How to Test Figma File Actions Without Clicking Through the Editor
In about 20 minutes you will have a plugin that creates, selects, and modifies Figma nodes, tested at three levels: pure unit, sandbox level with a mocked figma global, and end to end against figma.com.
Figma plugins run inside a sandboxed iframe with a global object called figma injected at runtime. That global is only real when the plugin is loaded through the Figma Desktop app or the web editor, which means you cannot simply import your plugin into a test file and call figma.createRectangle(). The trick is to test the three categories of code separately: pure helpers, code that touches the figma global, and real editor behavior. Each layer has a different cost and a different failure mode, and a healthy plugin codebase uses all three.
Prerequisites
- A Figma account with access to the Plugin Developer tools
- Node 20 or newer
- @figma/plugin-typings installed as a dev dependency
- A sample manifest.json with main and ui entry points
- Figma Desktop app (needed for local dev mode)
- A personal access token from the Figma account settings
1. Unit test pure helpers with Vitest
The first move is to separate logic from the figma API. Any function that computes something (a grid layout, a hex color transform, a variable name sanitizer) should live in its own module that does not import figma at the top level. Those modules are testable with Vitest in a normal Node process, no mocks required.
This layer catches math bugs fast and runs in milliseconds. If your plugin mostly shuffles numbers and strings before handing them to figma.createRectangle, most of your test coverage should live here. A useful rule of thumb: if a function does not call the figma global directly and does not return a Figma node, it belongs in a pure module, and it should have a unit test. That keeps the expensive sandbox and editor tests focused on real integration concerns rather than arithmetic you can verify in a plain Node process.
One tip: the plugin typings in @figma/plugin-typings declare the figma global at the ambient scope, which Vitest normally picks up. If TypeScript complains about the global in your pure modules, narrow the types you import from the package, or move the pure module into a subdirectory that does not include the typings in its tsconfig types array. That separation pays dividends later.
2. Mock the figma global for sandbox level tests
For code that actually calls the figma API (creating nodes, reading selection, setting properties), you need a fake figma global in the test environment. Vitest lets you attach globals in a setup file. The common gotcha is figma.currentPage.selection: the real runtime exposes it as a getter, so in a mock you have to define it with Object.defineProperty rather than assigning to it directly, or your plugin code that reads selection will get stale values.
Register the setup file in vitest.config.ts under test.setupFiles, then write tests against the plugin entry points that create and mutate nodes. A common pattern is a test that verifies undo semantics by snapshotting the node map before an action, running the action, then asserting the diff. Figma itself groups mutations during a single message handler into one undo step, so your mock should mirror that with a helper like withUndoGroup().
To cover undo and redo behavior, keep an in-memory stack of snapshots in the mock. Before every mutation, push a deep copy of the node map. On figma.triggerEvent("undo") (or whatever internal hook you expose for tests), pop and restore. This gets you close enough to the real runtime that you can assert things like "after one undo, the rectangle is gone, and after redo, it is back with the same id". You will not catch every edge case Figma handles natively, but you will catch the mistakes your plugin introduces, which is what tests at this layer are actually for.
Reset the mock in a beforeEach hook so tests do not share state. The cost of a fresh map per test is negligible, and the debugging time saved when a test stops failing because an earlier test happened to leave the right selection behind is significant.
Skip the figma global entirely
Assrt drives the real Figma web editor end to end, so you can assert on pixels instead of mocks. Open source, runs locally.
Get Started →3. Use real .fig fixtures via the REST API
Mocks lie. The surest way to test a file-level action (renaming all text layers, exporting variables, restructuring components) is to run it against a real file and inspect the result. Figma does not let you upload .fig files programmatically, but you can clone a known-good template file to a fresh duplicate per test using the REST API, then read the resulting structure back as JSON.
Two things to know. Free files have a stricter rate limit than team files (the public tier is roughly 2 requests per second on any given token, team tier is higher), so if your CI spawns many parallel jobs you want the fixture file to live in a paid team. And the document shape returned by /v1/files is serialized, so types like SymbolInstance become INSTANCE in the wire format. Keep a small translation layer rather than reusing the plugin typings directly.
The workflow that scales is: maintain one template file per feature area in a team you control, never edit those files by hand, and use the REST API to pull a snapshot at the start of each test. If your feature writes back to the file (for example a plugin that creates new frames), duplicate the template via the /v1/projects/:project_id/files endpoint before each test, run the feature on the duplicate, and delete it after. That keeps the template pristine and gives every test a deterministic starting state.
4. Drive the real editor with Playwright
For the last mile, run Playwright against a real logged-in Figma session. Export your session cookies once, save them as a storageState.json via Playwright, and reuse that state in every test. The canvas itself is a WebGL surface, so Playwright cannot see individual nodes through the DOM. You drive it with hotkeys (R for rectangle, V for the selection tool, Cmd+Z for undo) and verify results with screenshots plus the plugin UI iframe, which is a regular DOM tree.
When you do need to assert on canvas content, two approaches work. Pixel hashing a cropped screenshot is fast and deterministic for layout tests, and OCR via a small local model handles text you want to read back. If you need to click a specific node, either use Figma hotkeys to move selection or call out to the plugin UI to select by ID, then proceed with keyboard commands.
One practical wrinkle: Figma's session cookie rotates, and a staleness of a few weeks is enough to break E2E. Re-record storageState.json on a schedule (a short Playwright login script run monthly works), or use a long-lived personal access token for anything that can go through the REST API and save real browser sessions for the small number of flows that genuinely need the editor.
5. Wire it into CI
GitHub Actions is the common target. Three jobs: Vitest unit, Vitest with the figma mock, and Playwright E2E. Put the Figma token in repo secrets and skip the E2E job on pull requests from forks (secrets do not propagate, and running real editor tests without a token fails noisily). For public plugin repos, this fork gate is the single most important line in the workflow, because without it every first-time contributor's pull request will appear broken when it is the secrets that are missing, not the plugin.
6. Gotchas worth writing on a sticky note
A few things bite plugin authors repeatedly. First, the figma global only exists inside the sandbox iframe, so any attempt to share a module between the UI code and the main thread must stay pure or be guarded. Second, figma.currentPage.selection is a live getter: assigning to it replaces the selection, but reading it inside a setTimeout callback will give you whatever the user has selected at that later moment, not what was selected when you scheduled the timeout.
Third, the REST API returns file comments and reactions via /v1/files/:key/comments, which is useful for plugins that tag or resolve review feedback. Test that flow against a throwaway file so you are not polluting a real design with test comments. Fourth, Figma caches plugin bundles aggressively in the desktop app. Use the Plugins menu, Development, Manage plugins in development, then Use existing plugin, rather than republishing to dev mode over and over.
Fifth, Playwright cannot click inside the WebGL canvas, full stop. Every canvas-side interaction has to go through hotkeys, the plugin UI iframe, or the accessibility tree that Figma exposes for screen readers. If your feature needs a specific node selected before the test runs, make sure the plugin exposes a way to select by ID so the test has a stable handle.
Putting it together
The shape of a plugin test suite that holds up: sixty or seventy percent pure unit tests for logic, twenty percent sandbox tests with the mocked figma global for code that creates and mutates nodes, and ten to fifteen percent real editor tests in Playwright for the flows that absolutely have to work before a release. Fixtures pulled from a real team file via the REST API keep the middle layer honest, and session-based Playwright keeps the top layer fast enough to run on every pull request.
If the Playwright layer feels like too much work to author by hand, that is the use case Assrt was built for. Point it at your plugin preview URL, it discovers the scenarios your UI actually surfaces, and writes standard Playwright specs you can check into the repo. You still get to own the tests, they still read like any other file in your repo, and you skip the afternoon of figuring out which role selectors work through the nested iframe.