V8 Coverage Limitations and How to Work Around Them
Source: Dev.to
❗ The Blind Spot
V8 does not recognise branches that are inside a JSX expression container ({}) when the conditional returns JSX elements (as opposed to strings or primitives).
Ternary inside JSX
function UserStatus({ isLoggedIn }: { isLoggedIn: boolean }) {
return (
{isLoggedIn ? <LoggedIn /> : <LoggedOut />}
);
}
V8 reports 0 branches for the line containing the ternary.
Even if your test only renders with isLoggedIn={true}, V8 won’t warn that <LoggedOut /> was never rendered.
Logical AND inside JSX
function ErrorDisplay({ error }: { error?: string }) {
return (
{error && <ErrorMessage error={error} />}
);
}
Same problem – V8 reports 0 branches. Passing error={null} will not trigger a warning that <ErrorMessage /> was never rendered.
✅ What V8 does track correctly
| Pattern | Example | V8 status |
|---|---|---|
| Ternary / logical AND returning strings | {isLoggedIn ? 'Welcome!' : 'Please log in'}{error && 'Error occurred'} | ✅ Tracks branches |
if / else statements | if (isLoggedIn) { return <LoggedIn />; } else { return <LoggedOut />; } | ✅ Tracks branches |
| Ternary outside JSX container | return isLoggedIn ? <LoggedIn /> : <LoggedOut />; | ✅ Tracks branches |
The blind spot occurs only when:
- Using a ternary
? :or logical AND&&inside a JSX expression container ({})- The conditional returns JSX elements (not strings or other primitives)
📍 Blind‑spot examples
// ✗ V8 cannot track branch coverage
{condition ? <A /> : <B />}
{condition && <A />}
// ✓ Works – returning strings
{condition ? 'yes' : 'no'}
{condition && 'yes'}
// ✓ Works – using if/else
if (condition) return <A />;
return <B />;
🛠 Detecting Blind Spots with nextcov
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
📏 Real‑time detection – 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,
];
The plugin flags JSX ternary and logical‑AND patterns as errors, reminding you to test both branches.
✅ Simple solution: write tests for both branches
// 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')).toBeInDocument();
});
Even though V8 won’t count the branches, the tests will fail if either branch is broken.
🔧 Refactor: move the conditional outside the JSX container
Before (blind spot)
function Input({ error, helperText }: { error?: string; helperText?: string }) {
return (
<>
{error && <ErrorMessage>{error}</ErrorMessage>}
{helperText && !error && <Helper>{helperText}</Helper>}
</>
);
}
Result: V8 reports 4 branches total (none for the && lines).
After (V8 tracks correctly)
function Input({ error, helperText }: { error?: string; helperText?: string }) {
const errorElement = error ? (
<ErrorMessage>{error}</ErrorMessage>
) : null;
const helperElement = helperText && !error ? (
<Helper>{helperText}</Helper>
) : null;
return (
<>
{errorElement}
{helperElement}
</>
);
}
Result: V8 reports 7 branches, correctly covering the conditionals.
🔁 Double‑AND pattern
Before (blind spot)
{user && user.isAdmin && <AdminPanel />}
After (V8 tracks)
const adminPanel = user && user.isAdmin ? <AdminPanel /> : null;
/* later in JSX */
{adminPanel}
Key insight: Convert && chains that return JSX into explicit ternary expressions (? : null) outside the JSX container.
🧩 Refactor with if / else instead of JSX ternary
Before (blind spot)
function UserStatus({ isLoggedIn }: { isLoggedIn: boolean }) {
return (
{isLoggedIn ? <LoggedIn /> : <LoggedOut />}
);
}
After (V8 tracks)
function UserStatus({ isLoggedIn }: { isLoggedIn: boolean }) {
if (isLoggedIn) {
return <LoggedIn />;
}
return <LoggedOut />;
}
if / else statements are fully understood by V8, so coverage reports become accurate.
📋 Recommendations
- Write explicit tests for both branches of any JSX conditional, even if V8 can’t count them.
- Run
nextcov check(or the ESLint plugin) to surface blind‑spot patterns early. - Refactor:
- Move ternaries / logical‑ANDs that return JSX outside the JSX container.
- Prefer
if / elsestatements for complex branching.
- Be aware that V8 branch‑coverage numbers may be inflated when JSX ternaries are kept.
- Enable source maps in production builds if you need accurate line‑level reporting:
// next.config.ts
const nextConfig = {
productionBrowserSourceMaps: !!process.env.E2E_MODE,
webpack: (config) => {
if (process.env.E2E_MODE) {
config.devtool = 'source-map';
}
return config;
},
};
export default nextConfig;
- Remember that tree‑shaking and other code‑transformations can affect coverage accuracy because V8 reports byte ranges in the bundled output.
Coverage for Code That Doesn’t Exist in the Bundle
Why it matters
- Code‑splitting can load chunks conditionally – coverage depends on which chunks are loaded during tests.
- Minification without source maps makes coverage meaningless.
- V8 coverage is collected per‑process. For a Next.js app you must coordinate coverage from three places:
- Next.js server process (Server Components, Server Actions)
- Browser process (Client Components)
- Test‑runner process
Tip: Tools like nextcov handle this coordination automatically.
Limitations, Impact & Work‑arounds
| Limitation | Impact | Work‑around |
|---|---|---|
Ternary / logical AND returning JSX inside {} | Branch coverage not tracked | Write explicit tests for both branches, or use an ESLint plugin that forces explicit if/else. |
| Source‑map dependency | Coverage only on bundled code | Enable source maps for test builds (e.g., next build --sourceMaps). |
| Bundler transformations | Some code may be excluded from the bundle (e.g., dead‑code elimination) | Understand your bundler’s behavior and add /* @__PURE__ */ comments or /* webpackIgnore: true */ where needed. |
| Multi‑process coordination | Coverage reports are incomplete or fragmented | Use a coordination tool such as nextcov (or roll your own merging script). |
Key Takeaways
- V8 coverage is the practical choice for modern Next.js applications.
- Knowing its limitations helps you:
- Write more comprehensive tests.
- Interpret coverage reports accurately.
Helpful Resources
- 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
Related articles (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 and Component Test Coverage (coming soon)
- E2E Coverage in Next.js: Dev Mode vs Production Mode (coming soon)