V8 native coverage is powerful — it works with any bundler, has minimal overhead, and can collect coverage across multiple processes. But it has one specific limitation you need to understand when testing React applications.
The JSX Blind Spots
V8 coverage has blind spots for ternary operators and logical AND that return JSX inside {} (JSX expression containers):
Ternary Returning JSX
function UserStatus({ isLoggedIn }) {
return (
<div>
{isLoggedIn ? <WelcomeMessage /> : <LoginPrompt />}
</div>
);
}
V8 doesn't recognize this as a branch at all — it reports 0 branches for this line. Even if your test only renders with isLoggedIn={true}, V8 won't warn you that <LoginPrompt /> was never rendered.
Logical AND Returning JSX
function ErrorDisplay({ error }) {
return (
<div>
{error && <ErrorMessage message={error} />}
</div>
);
}
Same as the ternary case — V8 reports 0 branches for this line. Even if your test only passes error={null}, V8 won't warn you that <ErrorMessage /> was never rendered.
What Works Correctly
V8 tracks branches correctly for:
Ternary/logical AND returning strings (not JSX):
{isLoggedIn ? 'Welcome!' : 'Please log in'} // ✓ V8 tracks this
{error && 'Error occurred'} // ✓ V8 tracks this
If/else statements:
if (isLoggedIn) {
return <WelcomeMessage />;
} else {
return <LoginPrompt />;
} // ✓ V8 tracks this
Ternary outside JSX expression container:
return isLoggedIn ? <WelcomeMessage /> : <LoginPrompt />; // ✓ V8 tracks this
The Specific Blind Spots
The blind spot occurs only when:
- Using a ternary
? :or logical AND&& - Inside a JSX expression container
{} - Returning JSX elements (not strings or primitives)
// ✗ Blind spots - V8 cannot track branch coverage
<div>{condition ? <ComponentA /> : <ComponentB />}</div>
<div>{condition && <ComponentA />}</div>
// ✓ Works - returning strings
<div>{condition ? 'yes' : 'no'}</div>
<div>{condition && 'yes'}</div>
// ✓ Works - using if/else
if (condition) return <ComponentA />;
return <ComponentB />;
Detecting Blind Spots
Using nextcov check
The nextcov check command scans your codebase for these patterns:
npx nextcov check src/
Example output:
V8 Coverage Blind Spots Found:
────────────────────────────────────────────────────────────
src/components/UserStatus.tsx:5:7
⚠ JSX ternary operator (V8 cannot track branch coverage)
src/components/ErrorDisplay.tsx:4:7
⚠ JSX logical AND (V8 cannot track branch coverage)
────────────────────────────────────────────────────────────
Found 2 issues in 2 files
Using ESLint
For real-time detection during development, use eslint-plugin-v8-coverage:
npm install -D eslint-plugin-v8-coverage
// eslint.config.js
import v8Coverage from 'eslint-plugin-v8-coverage'
export default [
v8Coverage.configs.recommended,
]
This flags JSX ternary and logical AND patterns as errors, reminding you to ensure both branches are tested.
Workarounds
1. Write Explicit Tests for Both Branches
The simplest solution: ensure your tests cover both cases.
// UserStatus.test.tsx
it('shows welcome message when logged in', () => {
render(<UserStatus isLoggedIn={true} />);
expect(screen.getByText('Welcome!')).toBeInTheDocument();
});
it('shows login prompt when not logged in', () => {
render(<UserStatus isLoggedIn={false} />);
expect(screen.getByText('Please log in')).toBeInTheDocument();
});
Even though V8 won't track the branch coverage accurately, your tests will fail if either branch is broken.
2. Extract Conditional to a Variable
Move the conditional outside the JSX expression container:
// Before: V8 blind spot (0 branches reported)
function Input({ error, helperText }) {
return (
<div>
<input />
{error && <p className="text-red-600">{error}</p>}
{helperText && !error && <p className="text-gray-500">{helperText}</p>}
</div>
);
}
// After: V8 tracks branches correctly (7 branches vs 4 before)
function Input({ error, helperText }) {
const errorElement = error ? (
<p className="text-red-600">{error}</p>
) : null;
const helperElement = helperText && !error ? (
<p className="text-gray-500">{helperText}</p>
) : null;
return (
<div>
<input />
{errorElement}
{helperElement}
</div>
);
}
By extracting the ternary to a variable outside the JSX {}, V8 can properly track the branch. The variable reference {errorElement} inside JSX is just a simple expression, not a conditional.
In the example above, the "before" version reports only 4 branches total (none for lines with &&). After extracting to variables, V8 reports 7 branches and correctly tracks coverage for the conditionals.
Double AND pattern:
// Before: V8 blind spot (0 branches for this line)
{user && user.isAdmin && <AdminPanel />}
// After: V8 tracks branches correctly
const adminPanel = user && user.isAdmin ? <AdminPanel /> : null;
// ...
{adminPanel}
The key insight: convert && chains that return JSX into explicit ternary expressions (? : null) outside the JSX container.
3. Use Early Returns with if/else
For simpler components, use if/else statements instead of JSX ternaries:
// Before: V8 blind spot
function UserStatus({ isLoggedIn }) {
return (
<div>
{isLoggedIn ? <WelcomeMessage /> : <LoginPrompt />}
</div>
);
}
// After: V8 tracks branches correctly
function UserStatus({ isLoggedIn }) {
if (isLoggedIn) {
return (
<div>
<WelcomeMessage />
</div>
);
}
return (
<div>
<LoginPrompt />
</div>
);
}
V8 correctly tracks if/else statement branches, so coverage reports will be accurate.
4. Accept the Limitation
Sometimes the cleanest code uses JSX ternaries, and that's fine. Just be aware:
- V8 coverage numbers for branch coverage may be inflated
- Write tests that explicitly cover both branches
- Use the ESLint plugin or
nextcov checkto identify these patterns
Other V8 Limitations
Source Map Dependency
V8 coverage reports byte ranges in bundled code. Without source maps, you can't map back to original files:
// next.config.ts
const nextConfig = {
productionBrowserSourceMaps: !!process.env.E2E_MODE,
webpack: (config) => {
if (process.env.E2E_MODE) {
config.devtool = 'source-map'
}
return config
},
}
Bundler Transformations
Code transformations can affect coverage accuracy:
- Tree shaking removes unused code — can't measure coverage for code that doesn't exist in the bundle
- Code splitting may load chunks conditionally — coverage depends on which chunks are loaded during tests
- Minification without source maps makes coverage meaningless
Multi-Process Coordination
V8 coverage is collected per-process. For Next.js applications, you need to coordinate coverage from:
- Next.js server process (Server Components, Server Actions)
- Browser process (Client Components)
- Test runner process
Tools like nextcov handle this coordination automatically.
Summary
| Limitation | Impact | Workaround |
|---|---|---|
Ternary/logical AND returning JSX inside {}
|
Branch coverage not tracked | Write explicit tests for both branches, use ESLint plugin |
| Source map dependency | Coverage on bundled code only | Enable source maps for test builds |
| Bundler transformations | Some code may be excluded | Understand your bundler's behavior |
| Multi-process coordination | Coverage incomplete | Use tools like nextcov |
V8 coverage is the practical choice for modern Next.js applications, but understanding its limitations helps you write more comprehensive tests and interpret coverage reports accurately.
References
- nextcov — E2E coverage for Next.js with Playwright
- eslint-plugin-v8-coverage — Detect V8 coverage blind spots
- V8 Blog: JavaScript Code Coverage — How V8 coverage works at the engine level
This is part 4 of a series on test coverage for modern React applications:
- nextcov - Collecting Test Coverage for Next.js Server Components
- Why Istanbul Coverage Doesn't Work with Next.js App Router
- V8 Coverage vs Istanbul: Performance and Accuracy
- V8 Coverage Limitations and How to Work Around Them (this article)
- How to Merge Vitest Unit, Component, and E2E Test Coverage
- E2E Coverage in Next.js: Dev Mode vs Production Mode
Top comments (0)