Testing
Testing code that uses the library by injecting fetch, storage and a logger — with no module mocks at all.
The library takes its fetch, its storage and its logger as configuration. So testing code that calls it
needs no module interception: build a client with test doubles, and it behaves exactly as it does in production
minus the network.
const client = createAtomicClient({
app: "test",
storagePrefix: "test:",
auth: { fetch: fakeFetch, storage: { session: memory(), local: memory() } },
});Why this matters more than convenience
A module mock replaces the library, so the test passes whatever the library does. Injection keeps the real code path — the headers it builds, the token it stores, the recovery it attempts — and only replaces the boundary. What the test exercises is what production runs.
A test harness
import { LogLevel, configureLogger, createAtomicClient } from "@sinequa/atomic";
import type { AtomicClient, TokenStorage } from "@sinequa/atomic";
/** An in-memory TokenStorage — what the library falls back to without a window. */
export function memoryStorage(): TokenStorage {
const entries = new Map<string, string>();
return {
get: (key) => entries.get(key) ?? null,
set: (key, value) => {
entries.set(key, value);
},
remove: (key) => {
entries.delete(key);
},
};
}
export type Recorded = { url: string; method: string; body?: string };
/**
* A fetch that answers from a table of canned responses and records every call.
* The signature is the platform's, so the library uses it unchanged.
*/
export function stubFetch(routes: Record<string, unknown>) {
const calls: Recorded[] = [];
const fetch: typeof globalThis.fetch = async (input, init) => {
const url = String(input);
calls.push({
url,
method: init?.method ?? "GET",
body: typeof init?.body === "string" ? init.body : undefined,
});
const match = Object.keys(routes).find((path) => url.includes(path));
if (!match) {
// A test should fail loudly on an unexpected call, not silently 404.
return new Response(JSON.stringify({ errorMessage: `no stub for ${url}` }), {
status: 500,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify(routes[match]), {
status: 200,
headers: { "content-type": "application/json" },
});
};
return { fetch, calls };
}
/** A fresh client per test: its session, storage and events are its own. */
export function testClient(routes: Record<string, unknown>): {
client: AtomicClient;
calls: Recorded[];
} {
const { fetch, calls } = stubFetch(routes);
configureLogger({ level: LogLevel.NONE });
const client = createAtomicClient({
app: "test",
backendUrl: "https://server.test",
// A distinct prefix, so tests running in one process cannot see each other.
storagePrefix: `test-${calls.length}:`,
auth: {
fetch,
storage: { session: memoryStorage(), local: memoryStorage() },
// Silence the library's own diagnostics without touching the console.
logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
},
});
return { client, calls };
}import { createAtomicClient } from "@sinequa/atomic";
import type { AtomicClient, TokenStorage } from "@sinequa/atomic";
/** An in-memory TokenStorage — what the library falls back to without a window. */
export function memoryStorage(): TokenStorage {
const entries = new Map<string, string>();
return {
get: (key) => entries.get(key) ?? null,
set: (key, value) => {
entries.set(key, value);
},
remove: (key) => {
entries.delete(key);
},
};
}
export function stubFetch(routes: Record<string, unknown>) {
const calls: string[] = [];
const fetch: typeof globalThis.fetch = async (input) => {
const url = String(input);
calls.push(url);
const match = Object.keys(routes).find((path) => url.includes(path));
// A test should fail loudly on an unexpected call, not silently 404.
return new Response(JSON.stringify(match ? routes[match] : { errorMessage: url }), {
status: match ? 200 : 500,
headers: { "content-type": "application/json" },
});
};
return { fetch, calls };
}
/** A fresh client per test: its session, storage and events are its own. */
export function testClient(routes: Record<string, unknown>): {
client: AtomicClient;
calls: string[];
} {
const { fetch, calls } = stubFetch(routes);
const client = createAtomicClient({
app: "test",
backendUrl: "https://server.test",
storagePrefix: "test:",
auth: {
fetch,
storage: { session: memoryStorage(), local: memoryStorage() },
logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
},
});
return { client, calls };
}import { SINEQUA } from "./sinequa";
import { testClient } from "../test/harness";
/**
* The token is what the service under test receives, so overriding it with a test
* client needs no module interception — the injector does the substitution.
*
* `describe`/`it`/`expect` come from your test runner.
*/
declare const describe: (name: string, fn: () => void) => void;
declare const it: (name: string, fn: () => Promise<void> | void) => void;
declare const expect: (value: unknown) => { toBe(expected: unknown): void };
describe("SearchService", () => {
it("sends the configured query name", async () => {
const { client, calls } = testClient({
"api/v1/query": { records: [], rowCount: 0, aggregations: [] },
});
// In a real spec: TestBed.configureTestingModule({
// providers: [{ provide: SINEQUA, useValue: client }],
// });
void SINEQUA;
await client.api.query.search({ name: "<your-query>", text: "hello" });
expect(calls.length).toBe(1);
});
});import { createAtomicClient } from "@sinequa/atomic";
import type { AtomicClient, TokenStorage } from "@sinequa/atomic";
/** An in-memory TokenStorage — what the library falls back to without a window. */
export function memoryStorage(): TokenStorage {
const entries = new Map<string, string>();
return {
get: (key) => entries.get(key) ?? null,
set: (key, value) => {
entries.set(key, value);
},
remove: (key) => {
entries.delete(key);
},
};
}
export function stubFetch(routes: Record<string, unknown>) {
const calls: string[] = [];
const fetch: typeof globalThis.fetch = async (input) => {
const url = String(input);
calls.push(url);
const match = Object.keys(routes).find((path) => url.includes(path));
// A test should fail loudly on an unexpected call, not silently 404.
return new Response(JSON.stringify(match ? routes[match] : { errorMessage: url }), {
status: match ? 200 : 500,
headers: { "content-type": "application/json" },
});
};
return { fetch, calls };
}
/** A fresh client per test: its session, storage and events are its own. */
export function testClient(routes: Record<string, unknown>): {
client: AtomicClient;
calls: string[];
} {
const { fetch, calls } = stubFetch(routes);
const client = createAtomicClient({
app: "test",
backendUrl: "https://server.test",
storagePrefix: "test:",
auth: {
fetch,
storage: { session: memoryStorage(), local: memoryStorage() },
logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
},
});
return { client, calls };
}What each injection point buys
Prop
Type
Testing what the library does, not just what it returns
Because the request path is real, a test can assert on things a mock would have hidden:
const { client, calls } = testClient({ "api/v1/query": { records: [], rowCount: 0, aggregations: [] } });
await client.api.query.search({ name: "<your-query>", text: "hello" });
// The body carries app, locale, noUserOverride and noAutoAuthentication.
const body = JSON.parse(calls[0].body ?? "{}");That is how the library's own suite verifies the auth flows — the sign-in sequence, the 401 recovery, the deliberate sign-out window — without a single module mock.