I spent more time feeling guilty about missing tests than it would have taken to just write them. So I built something to stop doing either.
Lacuna is an open-source CLI that closes test coverage gaps automatically. You run one command, and it finds every untested file in your project, generates real, runnable tests using AI, verifies they pass, and tells you how much coverage improved.
Why we built it
Writing tests is the part of software development that everyone agrees matters, and nobody wants to do.
We wanted a tool that fit into existing workflows, not a replacement for TDD or another AI chat interface. Something you could run locally while you work, generating missing tests and highlighting coverage gaps before code ever leaves your machine. And if you want the same checks in CI, it can run on every PR, post a coverage report, and keep everyone honest without getting in the way.
That became Lacuna.
- -
How it works
Lacuna has four core commands:
lacuna init # Initializes the tool, installs deps, sets up mocks and config files
lacuna analyze # read-only: shows you exactly what's missing
lacuna generate # writes tests for uncovered files and verifies they pass
lacuna fix # repairs existing failing tests without rewriting them
The generate loop is where the interesting stuff happens:
- Runs your test suite and collects coverage
- Finds files below your threshold and files with no tests at all
- For each gap: reads the source file, builds context (types, imports, mocks, setup file), sends it to the model
- Writes the generated test file to disk
- Runs the test. If it passes, it moves on. If it fails, it retries with the error output (up to 3 attempts by default, or maxIterations as configured) and regenerates the test file at the end if it wasn't able to fix the failing tests. You can disable this with
--no-regenerate-on-failure - Reports what changed So it's not just generating code, it's generating code that runs. The loop doesn't succeed until the tests actually pass.
If you run
lacuna generate -f PATH_TO_SOURCE_FILE, it will either create a new test file or update the existing tests by increasing coverage
Amazing, isn't it?
Getting started
$ npm install -g lacuna-cli
Run the setup wizard in your project:
$ lacuna init
This detects your test runner (Vitest, Jest, Mocha, pytest, Go test, and more), installs any missing dependencies, creates a config file, and sets up your coverage infrastructure. For React, Next.js, and React Native projects, it also creates a setup file pre-loaded with the right mocks.
Then add your api key with export DEEPSEEK_API_KEY=your_api_key and run:
$ lacuna generate
That's it. Go get a coffee.
Why DeepSeek is the recommended model
We tested Lacuna against several models and settled on DeepSeek Chat as the default. Here's why:
It's cheap. DeepSeek's pricing is roughly 10–20x cheaper than GPT-4o and Claude Sonnet for the same task. Running Lacuna across a large codebase costs cents, not dollars.
No rate limit headaches. Anthropic's Tier 1 accounts have an 8,000 output-token-per-minute cap. When you're generating 300-line test files in parallel, you hit that wall fast. DeepSeek doesn't have this problem at typical lacuna usage volumes.
It gets the job done. The tests it writes are correct, idiomatic, and pass on the first attempt the majority of the time. For React and Next.js projects in particular, it handles mocking, async patterns, and component testing reliably.
Get your DeepSeek API key at platform.deepseek.com; it takes two minutes and comes with free credits.
Here's what the .lacuna.json file would look like if Deepseek is selected in the lacuna init command
{
"provider": "openai-compatible",
"model": "deepseek-chat",
"baseURL": "https://api.deepseek.com/v1",
"apiKeyEnv": "DEEPSEEK_API_KEY"
// ... other config
}
You can always switch models with the -m flag: lacuna generate -m claude-sonnet-4–6 or lacuna generate -m gemini-2.5-flash Lacuna works with any OpenAI or anthropic-compatible provider.
Here's a video that shows how the lacuna generate command works; I passed in the --verbose flag so I can stream the model's output in real time.
CI integration
The real power is running lacuna automatically on every PR. Add this to .github/workflows/lacuna.yml
# Copy this file to YOUR repo at .github/workflows/lacuna.yml
# Lacuna will run on every PR, generate missing tests, and post a coverage report.
name: lacuna coverage
on:
pull_request:
branches: [main, master]
jobs:
coverage:
runs-on: ubuntu-latest
permissions:
contents: write # needed to commit generated test files
pull-requests: write # needed to post PR comments
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
# Install your project dependencies first
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- name: Run lacuna
id: lacuna
uses: Octagon-simon/lacuna@v1
continue-on-error: true # allow commit step to run even if coverage is below threshold
with:
mode: generate # generate tests (use 'analyze' for read-only)
threshold: 80 # fail PR if coverage stays below this
workers: 2 # parallel workers - increase for larger repos
model: deepseek # default - cost-effective, no rate-limit issues
deepseek-api-key: ${{ secrets.DEEPSEEK_API_KEY }}
github-token: ${{ secrets.GITHUB_TOKEN }}
post-comment: true
# Commit any test files lacuna wrote back to the PR branch.
# Runs even when lacuna exits with code 1 (below threshold) so generated
# tests are never lost. Skips the commit if nothing was written.
- name: Commit generated tests
if: steps.lacuna.outcome != 'cancelled'
run: |
git config user.name "lacuna[bot]"
git config user.email "lacuna[bot]@users.noreply.github.com"
git add -A
git diff --staged --quiet || git commit -m "chore: lacuna - add generated tests"
git push
On every PR, lacuna will generate missing tests, commit them to the branch, and post a coverage report as a comment. If coverage stays below your threshold, the check fails, blocking the merge until tests are added.
- -
What we've tested
React, Next.js, and React Native work great. Lacuna detects both automatically during lacuna init and handles the specific challenges each brings:
- Next.js App Router components: global mocks for next/navigation, next/headers, next/cache, next/image, and next/font are pre-configured in the setup file
- React hooks, context, native modules, Expo modules, async state updates; properly wrapped in act(), waitFor(), and the right testing patterns
- TypeScript strict mode; generated tests compile without errors
The retry loop catches most of what goes wrong on the first pass (wrong mock paths, unhandled promise rejections, missing imports) and fixes it automatically.
- -
What still needs work, and where you come in
lacuna init now supports detection for Vue, Svelte, Angular, NestJS, PHP (PHPUnit/Pest), Ruby (RSpec), Rust (cargo test), C# (dotnet test), Java/Kotlin (Gradle/Maven), and Swift, but these haven't been battle-tested the way the React/React Native/Next.js paths have been.
The core test generation loop is language-agnostic, so the model can generate reasonable tests for most languages. The gaps are:
- Coverage integration: some runners (PHPUnit, JaCoCo, SimpleCov) don't output LCOV natively, lacuna analyze needs a converter step. lacuna generate --file works immediately without coverage.
- Framework-specific prompts: Vue and Angular need the same level of targeted mocking guidance that Next.js has. The more real test failures we see, the sharper the prompts get.
- Runner edge cases: CI environments, monorepos, and custom test configurations surface issues that local testing doesn't catch.
If you're using one of these stacks and want to contribute, this is where the leverage is. Opening an issue with a real failing test output, or a PR that improves the prompt guidance for your framework, directly improves lacuna for everyone using that stack.
Contributions are welcome
The repo is at github.com/Octagon-simon/lacuna.
The bottom line
Lacuna won't replace thoughtful test design for complex business logic. But for the 60–70% of a codebase that needs coverage and never gets it, utility functions, API route handlers, data transformations, component rendering, it does the work reliably and cheaply.
Run it locally while you build, and coverage stops being a problem for future you. Adding it to your PR pipeline is a nice bonus, but the biggest win is catching gaps before code ever leaves your machine.
Cover Photo by Steve A Johnson on Unsplash




Top comments (0)