Loading repository data…
Loading repository data…
two-go-testing / repository
Zero-dependency fluent API and service testing for Node.js: chainable HTTP, rich assertions, soft assertions, polling, snapshots, sessions, faker and a utility belt, plus an optional AI layer (test generation, failure explanation, bug review and fuzzing) and an MCP server so agents can drive it.
A transparent discovery signal based on current public GitHub metadata.
This score does not audit code, security, maintainers, documentation quality, or suitability. Verify the repository and its current documentation before adoption.
two-go is a small library for testing HTTP APIs from Node. You build a request
with a chainable API, attach the checks you care about, and await it. If a
check fails it throws, so you don't need a special test runner. It works on its
own, and it works inside node:test, Jest, Vitest, or Mocha without any plugin.
I got tired of either clicking through a GUI or wiring up an HTTP client plus an assertion library plus a dozen helpers for every project. two-go puts the parts I keep reaching for in one place, and it has no dependencies, so installing it doesn't drag in half of npm.
import { go } from "two-go";
await go("https://api.example.com")
.get("/users")
.bearer(token)
.expectStatus(200)
.expectHeader("content-type", /json/)
.expectJson("data[0].id", 1);
That one chain sends the request and runs all three checks. If any of them
fails you get an error like GET https://api.example.com/users -> expected status 200 but got 500.
The HTTP client and inline checks are the core. Around them there's a value
assertion API (expect), soft assertions, polling for slow endpoints, JSON
snapshots, session/auth chaining, a fake-data generator, async helpers, a
general utility belt, JSON schema validation, and importers that turn an
OpenAPI or Postman file into a test suite.
There's also an optional AI layer (two-go/ai): it can draft a test suite from
a live endpoint, explain why a test failed, review a response for likely bugs,
and generate adversarial payloads to fuzz an endpoint. It talks to providers
over fetch with your own key, so the core stays dependency free. And there's an
MCP server (two-go-mcp) so an agent like Claude can drive two-go as a set of
tools.
All of it ships with TypeScript types and zero runtime dependencies.
Two groups, and the package works for both:
If you do test automation or QA, you get HTTP checks, polling for eventual consistency, soft assertions so one run reports every problem, snapshots, login chaining, fake data, and a CLI to run whole folders of tests.
If you're a backend or full-stack dev, you write integration tests right next
to your unit tests in whatever runner you already use, and you can reuse the
expect, the utility belt, and the schema tools in app code too.
One thing to be clear about: two-go is for API and service testing (integration and e2e). It is not a unit test runner. It plugs into the runner you already have. The checks just happen to read like the unit test assertions you're used to.
npm install two-go --save-dev
You need Node 18 or newer, because it uses the built-in fetch. It's ESM only,
so use import (or a dynamic import() from CommonJS). There are no runtime
dependencies.
import { go } from "two-go";
const api = go("https://api.example.com");
const res = await api
.get("/users")
.query({ page: 1 })
.expectStatus(200)
.expectJson("data[0].id", 1);
// You still get the response back to poke at.
console.log(res.status, res.time, res.get("data[0].name"));
go() takes a base URL, or an options object if you want default headers and a
timeout for every request:
const api = go({
baseURL: "https://api.example.com",
headers: { "x-api-key": "secret" },
timeout: 10000,
});
There isn't much to learn. There's a client, a request builder, and a response.
GoClient comes from go(). It holds the base URL, default headers, and
timeout, and it has one method per HTTP verb.
RequestBuilder is what api.get("/x") returns. You chain configuration and
checks onto it, then await it. It's a thenable, so awaiting it is what
actually sends the request. You can also call .run() if you prefer.
GoResponse is the resolved result. It carries the parsed body and the
metadata, plus every check method.
const builder = api.get("/users"); // nothing sent yet
const res = await builder; // now it's sent, checks ran
A GoResponse gives you:
| Field | Type | What it is |
|---|---|---|
status | number | HTTP status code |
statusText | string | HTTP status text |
headers | object | response headers, keys lowercased |
body | any | parsed JSON when the content type is JSON, otherwise the raw text |
text | string | the raw body as text |
time | number | round trip time in ms |
url | string | the final URL that was called |
method | string | the HTTP method |
get(path) | method | pull a value out of body with a dot/bracket path |
Pull everything from two-go, or grab just one area from a subpath. Every
subpath ships its own types.
| Import | What you get |
|---|---|
two-go | everything (the default go, all checks, utilities, features) |
two-go/utils | the utility belt as flat named exports |
two-go/expect | the standalone expect() |
two-go/schema | validate and isValid |
two-go/soft | soft assertions |
two-go/eventually | eventually and pollUntil |
two-go/snapshot | snapshot testing |
two-go/session | stateful request chaining |
two-go/faker | the fake data generator |
two-go/async | async control flow helpers |
two-go/curl | curl export and logging |
two-go/infer-schema | schema inference |
two-go/importers | OpenAPI and Postman importers |
two-go/ai | optional AI layer (provider plus test generation) |
two-go/mcp | the MCP server |
two-go/bdd | runner-agnostic given/when/then helpers |
Every verb (get, put, post, patch, delete, head, options) returns
a builder you can chain.
await api.post("/users")
.header("x-request-id", "abc") // one header
.headers({ "x-trace": "1", lang: "en" }) // several at once
.query({ page: 1, tags: ["a", "b"] }) // array values repeat the key
.bearer("TOKEN") // authorization: Bearer TOKEN
.json({ name: "Ada", role: "admin" }) // JSON body and content-type
.timeout(5000) // overrides the client default
.expectStatus(201);
| Method | What it does |
|---|---|
.header(name, value) | set one header |
.headers(obj) | merge several headers |
.query(obj) | add query params (array values repeat the key) |
.bearer(token) | set authorization: Bearer <token> |
.json(obj) | JSON body plus content-type: application/json |
.form(obj) | URL encoded body plus the matching content type |
.text(str) | raw text body (text/plain unless you already set a type) |
.timeout(ms) | per request timeout, overrides the client default |
.run() | send now and return a Promise<GoResponse> |
A few things worth knowing. If you pass an absolute http(s):// URL to a verb,
the base URL is ignored. Timeouts use AbortController and reject with a clear
message when they fire. Header keys are lowercased so merging behaves the same
every time.
You can queue checks on the builder, where they run in order once you await it,
or call them on a resolved response. Either way they return the response so you
can keep chaining, and they throw an AssertionError on failure. Messages look
like <METHOD> <URL> -> <what went wrong>.
Status:
| Check | Passes when |
|---|---|
expectStatus(code) | status equals code |
expectStatusIn(...codes) | status is one of codes |
expectOk() | status is 2xx |
expectClientError() / expectServerError() / expectRedirect() | 4xx / 5xx / 3xx |
expectCreated() / expectAccepted() / expectNoContent() | 201 / 202 / 204 |
expectBadRequest() / expectUnauthorized() / expectForbidden() / expectNotFound() | 400 / 401 / 403 / 404 |
Headers and cookies:
| Check | Passes when |
|---|---|
expectHeader(name, matcher?) | header is present, and matches if you pass a matcher |
expectHeaderContains(name, substr) | header value contains the substring |
expectHeaderAbsent(name) | header is not present |
expectContentType(type) | content type contains type |
expectCookie(name, matcher?) | a set-cookie is present, and matches if given |
Body and JSON:
| Check | Passes when |
|---|---|
expectJson(path, expected?) | value at path exists, and matches if you pass expected |
expectJsonLength(path, n) | array or string at path has length n |
expectArrayLength(path, n) | same thing, named for arrays |
expectJsonContains(path, value) | array contains a matching item (objects match by subset) |
expectJsonSchema(schema) | the body validates against a JSON schema |
expectSorted(path, options?) | the array at path is sorted ({ key?, order? }) |
expectBody(matcher) | the whole body matches (deep compare for objects) |
expectBodyContains(substr) | the raw text body contains substr |
expectEmpty() / expectNotEmpty() | the body is empty / not empty |
expectTimeBelow(ms) | the round trip was under ms |
check(label, fn) | fails if fn(response) returns false or throws |
expectValue(path) | hands you an expect() bound to the value at path |
const res = await api.get("/users").expectOk();
res.expectValue("data[0].id").toBeGreaterThan(0);
res.expectValue("data").toHaveLength(2);
res.expectJsonContains("users", { id: 2 }); // subset match
res.expectSorted("data", { key: "id", order: "asc" });
About matchers: expectHeader, expectJson, and expectBody take a flexible
matcher. A RegExp is tested against the stringified value. A function is treated
as a predicate, so a truthy return passes. An object or array is compared
deeply. Anything else is compared with ===.
await api.get("/users")
.expectJson("data[0].role", (role) => role === "admin")
.expectJson("meta", { page: 1, total: 2 })
.expectHeader("x-trace-id", /^[0-9a-f-]+$/);
expect(value) is not tied to responses, you can use it on anything. It has
.not for negation and .resolves / .rejects for promises.
import { expect } from "two-go";
expect(2 + 2).toBe(4);
expect({ a: 1, b: 2 }).toMatchObject({ a: 1 }