Hello. In the previous lesson, you made expected failures explicit with Result and ResultAsync, while keeping exception translation at narrow infrastructure boundaries. That design now pays off in testing: an application handler can be exercised with predictable inputs and a controlled dependency, without a real Frida device, a live Windows process, or terminal I/O.
This lesson tests the asynchronous selectTarget application service introduced previously. You will use Vitest to verify its observable behavior, supply a typed test double for its ProcessProvider dependency, and make assertions that remain reliable under refactoring. The aim is not merely to get green tests: it is to establish a test style suitable for the vertical feature slices that will follow.
What this unit test is responsible for
Recall the service contract:
- It receives PID text from an outer layer such as a future CLI or REPL.
- It parses that text into a branded
ProcessId. - It asks a
ProcessProviderport to find the process. - It returns either a selected target or a typed application error.
The service should not know whether the provider uses Frida, a fixture process, a cached process list, or an in-memory implementation. That architectural separation gives us a clean unit-test seam.
A unit test of selectTarget should verify behavior such as:
- A valid PID and a found process produce the selected-target result.
- A process-not-found result is preserved.
- Invalid PID text prevents a provider lookup.
It should not test Frida enumeration, Windows permissions, process lifetime, or actual PID allocation. Those are integration concerns for later modules. A unit test intentionally replaces that external uncertainty with a test double.
Testing Asynchronous Code | Guide
Read Vitest’s official guide to establish the mechanics behind the tests you will write: awaiting async work, choosing promise assertions carefully, and avoiding silently unfinished async tests.
In the “Async/Await” section, read the async await pattern. Then continue through “Resolves and Rejects,” paying particular attention to the requirement to await promise assertions. In “Assertion Counting,” read assertion counting; note why it is mainly useful for assertions hidden in callbacks or branches. Finally, in the “Unhandled Rejections” portion of the “Timeouts” section, read unhandled rejections. For this project, an unhandled rejection generally means a violated async boundary rather than an expected application error.
ResultAsync failure is not promise rejection
This distinction is essential.
A ResultAsync<T, E> eventually resolves to one of two values:
Ok<T>Err<E>
Therefore, a test of a normal process-not-found outcome should use await selectTarget(...), inspect the resolved Result, and assert on its error value. It should not use .rejects, because process absence is an expected operational outcome, not a rejected promise.
A rejected promise would indicate that an adapter or dependency failed to honor its port contract. In production, the Frida adapter will translate third-party throws and rejections into UnexpectedFailure before application code receives them.
Test behavior, not implementation mechanics
A useful test has a stable reason to fail. Ask: if we refactor the internal implementation but preserve the service’s contract, should this test still pass?
For selectTarget, these are appropriate assertions:
| Behavior | Why it belongs in the test |
|---|---|
| The successful selected target contains the expected process identity and name | This is the service’s output contract |
process-not-found reaches the caller unchanged | The typed failure contract must be preserved |
| Invalid PID input does not invoke the provider | Parsing failure must short-circuit external work |
| The provider receives the parsed branded PID | This verifies the boundary between parsing and lookup |
These are generally inappropriate unit-test assertions:
| Implementation detail | Why to avoid it |
|---|---|
The exact sequence of map() and asyncAndThen() calls | Refactoring the composition should not invalidate behavior tests |
| Whether an internal local variable was created | It is invisible to callers |
| Frida API method calls | selectTarget does not depend on Frida directly |
| A real process’s PID or name | It makes a unit test environment-dependent |
The previous lesson’s use of a ProcessProvider port is what makes this possible. The service depends on a stable abstraction; the test supplies an implementation of that abstraction.
Choose a small, typed test double
“Mock” is often used as a catch-all term, but it is useful to distinguish several kinds of test doubles:
- A stub supplies a predetermined answer. Here, it can return either
okAsync(process)orerrAsync(error). - A spy records how it was used. Here, it can record whether
findByIdwas called and whichProcessIdit received. - A fake is a lightweight working implementation, such as an in-memory process provider backed by a
Map.
For this focused service, a Vitest vi.fn() is enough: it acts as both a stub, because it returns a controlled ResultAsync, and a spy, because it records invocations. We will create it afresh in each test rather than resetting shared mocks between tests.
Before writing the test, ensure the types needed by the test are exported from src/select-target.ts. In the version from the previous lesson, change the declaration of ProcessSummary to an exported type:
export type ProcessSummary = Readonly<{
processId: ProcessId;
name: string;
}>;
ProcessProvider was already exported. This is a reasonable export because it is part of the port’s public contract, not an internal implementation detail.
Vitest Crash Course | Tutorial from WebDevSimplified
Watch “Vitest Crash Course | Tutorial from WebDevSimplified” on the MasterDotDev channel for a concise demonstration of vi.fn(), call assertions, stubs, and spies. Use it to reinforce the distinction between controlling a dependency and testing the service itself.
In the segment on stubs, mocks, and spies, watch test doubles. Focus on how vi.fn() creates a controllable function that also records calls, and on the warning against replacing so much behavior that a test becomes detached from production code. In our case, only the ProcessProvider dependency is replaced; selectTarget itself runs unmodified.
Write the selectTarget test suite
Place the test next to its source file:
src/
identifiers.ts
errors.ts
parse-process-id.ts
select-target.ts
select-target.test.ts
Colocation makes the test easy to find when the service changes. It also maps naturally to the small feature slices you will introduce in the next module.
Create src/select-target.test.ts:
import {
errAsync,
okAsync,
type ResultAsync,
} from 'neverthrow';
import {
describe,
expect,
test,
vi,
} from 'vitest';
import type {
ProcessNotFoundError,
UnexpectedFailure,
} from './errors.js';
import {
processIdFromValidated,
type ProcessId,
} from './identifiers.js';
import {
selectTarget,
type ProcessProvider,
type ProcessSummary,
} from './select-target.js';
type LookupError =
| ProcessNotFoundError
| UnexpectedFailure;
interface FindByIdDouble {
(
processId: ProcessId,
): ResultAsync<ProcessSummary, LookupError>;
}
function createProvider(
outcome: ResultAsync<ProcessSummary, LookupError>,
): Readonly<{
provider: ProcessProvider;
findById: ReturnType<typeof vi.fn<FindByIdDouble>>;
}> {
const findById = vi.fn<FindByIdDouble>();
findById.mockReturnValue(outcome);
const provider: ProcessProvider = {
findById,
};
return {
provider,
findById,
};
}
describe('selectTarget', function () {
test(
'returns a selected target after finding the parsed process ID',
async function () {
const processId = processIdFromValidated(4420);
const process: ProcessSummary = Object.freeze({
processId,
name: 'fixture.exe',
});
const foundProcess: ResultAsync<
ProcessSummary,
LookupError
> = okAsync(process);
const { provider, findById } = createProvider(foundProcess);
const result = await selectTarget('4420', provider);
expect(result.isOk()).toBe(true);
if (result.isErr()) {
throw new Error('Expected target selection to succeed.');
}
expect(result.value).toEqual({
processId,
processName: 'fixture.exe',
});
expect(findById).toHaveBeenCalledTimes(1);
expect(findById).toHaveBeenCalledWith(processId);
},
);
test(
'preserves process-not-found from the provider',
async function () {
const processId = processIdFromValidated(4420);
const notFound: ProcessNotFoundError = Object.freeze({
kind: 'process-not-found',
processId,
});
const missingProcess: ResultAsync<
ProcessSummary,
LookupError
> = errAsync(notFound);
const { provider, findById } = createProvider(missingProcess);
const result = await selectTarget('4420', provider);
expect(result.isErr()).toBe(true);
if (result.isOk()) {
throw new Error('Expected process lookup to fail.');
}
expect(result.error).toEqual(notFound);
expect(findById).toHaveBeenCalledTimes(1);
expect(findById).toHaveBeenCalledWith(processId);
},
);
test(
'returns invalid-input without querying the provider',
async function () {
const processId = processIdFromValidated(4420);
const process: ProcessSummary = Object.freeze({
processId,
name: 'unused.exe',
});
const unusedOutcome: ResultAsync<
ProcessSummary,
LookupError
> = okAsync(process);
const { provider, findById } = createProvider(unusedOutcome);
const result = await selectTarget('not-a-pid', provider);
expect(result.isErr()).toBe(true);
if (result.isOk()) {
throw new Error('Expected invalid PID text to fail.');
}
expect(result.error).toEqual({
kind: 'invalid-input',
field: 'pid',
value: 'not-a-pid',
reason: 'Expected a positive decimal process ID.',
});
expect(findById).not.toHaveBeenCalled();
},
);
});
If your source files are in a different directory, adjust the relative imports but retain ESM’s .js import specifiers.
Read the suite as Arrange, Act, Assert
Each test follows a consistent shape.
Arrange creates precise, local fixtures:
processIdFromValidated(4420)creates a branded PID without bypassing the project’s identifier conventions.processis fixed test data, not a real process discovered from Windows.okAsync(process)orerrAsync(notFound)determines the provider’s controlled response.createProvider()creates a new test double for this one scenario.
Act runs the real service:
const result = await selectTarget('4420', provider);
The await is non-negotiable. Without it, you would inspect the pending ResultAsync rather than its resolved Result.
Assert checks the observable contract:
expect(result.value).toEqual({
processId,
processName: 'fixture.exe',
});
toEqual() is appropriate for structured data because it compares object contents rather than object identity. The interaction assertions are intentionally narrow: the provider should be called once with the parsed PID on the valid path, and never called when input validation fails.
The guard clauses after isOk() and isErr() serve two purposes. They make a failed assumption produce an immediate test failure, and they let TypeScript narrow the Result so .value or .error is type-safe.
Why each test matters
The first test checks the primary path without coupling the service to Frida. If the internal code later changes from asyncAndThen() to an explicit if branch, the test should remain unchanged as long as the selected target remains correct.
The second test verifies error preservation. The provider already decided that the process does not exist; selectTarget should not replace that failure with a string, throw an exception, or conceal it behind a generic error. Future CLI and REPL renderers will rely on this consistency.
The third test verifies short-circuiting. An invalid PID is a local input error, so there is no reason to consult an external dependency. This matters operationally: a future Frida-backed provider may be slow or unavailable, but malformed user input should still receive a fast, deterministic response.
Notice what this test suite does not need:
- No
beforeEach()orafterEach(). - No global mock reset.
- No fake timers.
- No Windows-specific setup.
- No network, filesystem, or Frida dependency.
- No
.rejectsmatcher.
Each test makes its own provider and data. Consequently, tests can run in any order and in parallel without sharing state.
Keep asynchronous tests deterministic
Determinism means that a test’s result depends only on the scenario encoded in the test, not on timing, machine state, process state, or execution order.
For this project, use these default policies:
| Prefer | Avoid |
|---|---|
Fixed branded IDs such as processIdFromValidated(4420) | process.pid, discovered process IDs, or hard-coded real process names |
okAsync(...) and errAsync(...) from the port contract | Calling a real Frida device in a unit test |
Direct await followed by assertions | Fire-and-forget promises |
| Fresh doubles inside each test | Mutable module-level doubles shared across tests |
| Testing outcomes and necessary boundary calls | Testing helper methods or internal chain structure |
| Fake timers only when the behavior actually depends on time | Real setTimeout() delays in unit tests |
Use fake timers later when a feature genuinely owns time-based behavior, such as batched trace-event flushing or retry delays. They are not a default requirement for async code. A function can be asynchronous simply because it calls a dependency returning a promise-like value; no timer needs to be involved.
Likewise, use expect.assertions() or expect.hasAssertions() selectively. They are valuable when assertions are hidden inside callbacks, loops, or branches that might not execute. In the three tests above, direct assertions after await make their execution obvious, so assertion counting would add noise rather than safety.
Run this focused suite while developing:
pnpm vitest run src/select-target.test.ts
Then run the full unit suite before committing:
pnpm test
The exact second command depends on the script established in the project baseline. The important distinction is that the focused command gives rapid feedback during implementation, while the full suite catches accidental coupling between modules.
A small design signal from a difficult test
If testing a service requires elaborate module mocks, private-method access, timer manipulation, or environmental setup, pause before adding more test machinery. It may indicate that the service has taken on too many responsibilities or depends directly on infrastructure.
The selectTarget service is easy to test because it has:
- a narrow input,
- an explicit output type,
- one injected port,
- no terminal rendering,
- no direct Frida calls,
- no ambient process state.
This is not accidental. It is the practical payoff of dependency inversion and explicit Result-based failure handling. In the next module, you will formalize this style into DDD-informed vertical slices, where each feature owns its use cases and depends on ports rather than infrastructure implementations.
Key takeaways
A reliable Vitest unit test for an asynchronous TypeScript service should:
- Make the test callback
asyncandawaitthe service result. - Treat
ResultAsyncerrors as resolvedErrvalues, not rejected promises. - Replace slow or environment-dependent dependencies with typed test doubles.
- Test the real service while controlling only its dependencies.
- Assert outputs first, then assert interactions only when they are part of the contract.
- Use fixed fixtures and fresh doubles to keep tests independent and deterministic.
- Keep real Frida, Windows processes, and other infrastructure for later integration tests.
You now have a complete tested application service, not merely a function that happens to compile. The next module will use this same separation of domain language, application handlers, and infrastructure ports to organize the tool into cohesive vertical slices.
Can't find a good explanation? Sign up and we'll make it for you
Sign up