# Write Node Unit Tests

> Write Node.js/TypeScript unit tests for the playwright-wrapper layer. Use when: creating new Jest tests, mocking Playwright API calls, testing getters/interaction/browser-control functions in isolation, improving Node.js test coverage.

- Skill: `marketsquare/write-node-unit-tests` (Agent Skill)
- Install (CLI): `npx skillmds@latest add marketsquare/write-node-unit-tests`
- Raw SKILL.md: https://api.skillmd.com/api/skills/marketsquare/write-node-unit-tests/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: marketsquare (https://skillmd.com/u/marketsquare)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/marketsquare/write-node-unit-tests

---

# Write Node.js Unit Tests

Node.js unit tests live in `node/playwright-wrapper/__tests__/` and use **Jest + ts-jest**.

Tests must be readable and test one thing at a time. Use descriptive `it()` names that read as plain English sentences.

Run tests with invoke:

```bash
inv utest-node
inv utest-node --coverage   # generates HTML report at node/coverage/index.html
```

## File naming

Name test files after the source module they test: `getters.ts` → `getters.test.ts`.

## IDE typing for Jest

Every test file imports all Jest globals it uses from `@jest/globals`. If a Jest API such as `beforeEach` or `afterEach` is used without being imported, add it to the existing import line:

```typescript
import { beforeEach, describe, expect, it } from '@jest/globals';
```

Do not add a `/// <reference types="jest" />` directive — it is redundant when importing from `@jest/globals` and inconsistent with the rest of the test suite.

Keep using the global `jest` object for `jest.mock()`, `jest.fn()`, and `jest.mocked(...)`.
Avoid importing `jest` from `@jest/globals` in these tests, because it can lead to worse mock typing in this repo.

## Mocking external module dependencies

Use `jest.mock()` at the top of the file (before imports) to replace modules with controlled fakes.

### Mocking `playwright-invoke`

`findLocator` is the primary dependency in most getter/interaction functions. Replace it with a `jest.fn()` and keep `exists` real:

```typescript
jest.mock('../playwright-invoke', () => ({
    findLocator: jest.fn(),
    exists: jest.requireActual('../playwright-invoke').exists,
}));

import { findLocator } from '../playwright-invoke';
const mockFindLocator = jest.mocked(findLocator);
```

### Mocking the logger

Suppress log output to keep test output clean:

```typescript
jest.mock('../browser_logger', () => ({
    logger: { info: jest.fn(), error: jest.fn() },
}));
```

### Mocking Playwright errors

Use the real `errors` export from `playwright` — no mock needed. Throw `new errors.TimeoutError(...)` directly in test stubs.

## Creating mock locators

Build a helper that returns an object with all `Locator` methods stubbed. Pass `overrides` to change just one method per test:

```typescript
function makeMockLocator(overrides: Partial<{
    waitFor: jest.Mock;
    isVisible: jest.Mock;
    isEnabled: jest.Mock;
    getAttribute: jest.Mock;
    isEditable: jest.Mock;
    isChecked: jest.Mock;
    elementHandle: jest.Mock;
}> = {}) {
    return {
        waitFor: jest.fn().mockResolvedValue(undefined),
        isVisible: jest.fn().mockResolvedValue(true),
        isEnabled: jest.fn().mockResolvedValue(true),
        getAttribute: jest.fn().mockResolvedValue(null),
        isEditable: jest.fn().mockResolvedValue(true),
        isChecked: jest.fn().mockResolvedValue(false),
        elementHandle: jest.fn().mockResolvedValue(makeElementHandle()),
        ...overrides,
    } as unknown as Locator;
}
```

## Creating mock gRPC request objects

After the ts-proto migration, generated protobuf types are plain interfaces with direct property access — no getter methods. Mock them with a plain object cast to `any`:

```typescript
function makeRequest(selector = '#el', strict = false) {
    return { selector, strict } as any;
}
```

## Asserting on mock interactions

Use `toHaveBeenCalledWith` and `toHaveBeenCalledTimes` to verify how mocks were called — this is often the primary assertion in the wrapper layer:

```typescript
expect(mockFindLocator).toHaveBeenCalledWith(callOptions, '#el', false);
expect(mockFindLocator).toHaveBeenCalledTimes(1);
```

Use `toBe` for primitives and reference identity checks; use `toEqual` for deep object comparison.

## Async error tests

When testing rejected promises, call `expect.assertions(n)` at the top of the test. This ensures the test fails if the error path is never reached:

```typescript
it('throws when element is not found', async () => {
    expect.assertions(1);
    mockFindLocator.mockRejectedValue(new Error('not found'));
    await expect(getElementStates(makeRequest(), {} as any)).rejects.toThrow('not found');
});
```

## Organising tests with nested describe

Group related cases under inner `describe` blocks to make large test files navigable:

```typescript
describe('getElementStates', () => {
    describe('when element is visible', () => {
        it('returns visible state', async () => { ... });
    });

    describe('when element times out', () => {
        it('returns detached state', async () => { ... });
    });
});
```

## Sequencing multiple `evaluate` calls

When a function calls `elementHandle.evaluate()` multiple times, use `mockResolvedValueOnce` chaining:

```typescript
const elementHandle = {
    evaluate: jest.fn()
        .mockResolvedValueOnce(false)   // first call: 'selected' in e
        .mockResolvedValueOnce(true),   // second call: document.activeElement === e
};
```

## Test structure

```typescript
describe('functionName', () => {
    beforeEach(() => {
        jest.clearAllMocks();
    });

    it('returns detached state when element times out', async () => {
        const locator = makeMockLocator({
            waitFor: jest.fn().mockRejectedValue(new errors.TimeoutError('Timeout')),
        });
        mockFindLocator.mockResolvedValue(locator);

        const result = await getElementStates(makeRequest(), {} as any);

        expect(JSON.parse(result.json)).toBe(2); // detached
    });
});
```

## Things to avoid

- Do not add comments or docstrings explaining what the test does — the test name and code should be self-explanatory.
- Mock at the boundary closest to your unit. Prefer spying over full module replacement when only one method needs controlling; don't mock modules that aren't exercised by the test.
- Do not assert on log messages — they are implementation details.
- Do not share mutable mock state between tests; always call `jest.clearAllMocks()` in `beforeEach`.

