Skip to content

Testing ​

A Rayfold server is an object. A test can build one from the application's real schema and resolvers and call it directly, in the test's own process: no port is opened, and nothing is mocked. Every call goes through the same argument checks, policies, idempotency and patches as a request over HTTP, so what a test proves is what a client will see.

Three rules keep these tests fast and reliable:

  • One caller per viewer. The viewer is who is calling, which over HTTP the server reads from a token. A test names it directly and keeps a caller for each kind of person: nobody, a customer, staff.
  • Never sleep. A test waits for a value, not for time to pass. Every wait on this page is bounded: a value that does not come fails the test with a message saying what it waited for, instead of hanging the run.
  • Give the server its clock. Anything that depends on time is tested by moving a clock the test owns.

Every example below is a test in this repository, run in CI: the bookshop examples' own, and for Angular the package's tests against the TypeScript bookshop. What the network adds (tokens, headers, status codes, CORS) belongs to the transport. Test it once, over HTTP, as the examples' other tests do.

Set up ​

ForAddYou get
TypeScript, React, Angularnothing: @rayfold/client has itcreateLocalTransport(server, viewer), and collect from @rayfold/client/testing
Kotlin, Java, Spring Bootdev.rayfold:rayfold-test, in test scopeRayfoldTest, a blocking caller per viewer, and LocalTransport for the Kotlin client

Since 0.2.2

dev.rayfold:rayfold-test, @rayfold/client/testing, the JVM server's clock and Angular's wait for a first answer came with 0.2.2. createLocalTransport and the TypeScript server's now were already in 0.2.1.

kotlin
dependencies {
    testImplementation("dev.rayfold:rayfold-test:0.2.3")
}
xml
<dependency>
  <groupId>dev.rayfold</groupId>
  <artifactId>rayfold-test</artifactId>
  <version>0.2.3</version>
  <scope>test</scope>
</dependency>

Then build the server under test and a caller for each viewer. Every test gets a server and a store of its own, so no test sees what another one bought.

ts
const schema = readFileSync(new URL("./bookshop.rayfold", import.meta.url), "utf8");

/** The shop and a client for each kind of caller. Nothing listens on a port, and every test gets a shop of its own. */
function bookshop(options: Partial<RayfoldServerOptions> = {}, store: Store = seed()) {
  const server = createRayfoldServer({ schema, resolvers: resolvers(store), ...options });
  // the viewer is who the caller is, which over HTTP the server reads from their token
  const as = (viewer: Viewer | null) => new RayfoldClient({ transport: createLocalTransport(server, () => viewer) });
  return {
    server,
    store,
    anonymous: as(null),
    customer: as({ id: "u1", role: "customer" }),
    staff: as({ id: "s1", role: "staff" }),
  };
}
kotlin
private val store = Store()
private val schema = SchemaText.load(checkNotNull(javaClass.getResource("/bookshop.rayfold")).readText()).ir
private val server = RayfoldServer(schema, resolvers(store))

// one caller per viewer: the value the schema's policies see as `viewer`
private val anyone = RayfoldTest.of(server)
private val customer = anyone.signedInAs(mapOf("id" to "u1", "role" to "customer"))
private val staff = anyone.signedInAs(mapOf("id" to "s1", "role" to "staff"))
java
Store store;
RayfoldServer server;
RayfoldTest anyone;
RayfoldTest customer;
RayfoldTest staff;

@BeforeEach
void start() {
    store = new Store();
    server = Bookshop.server(store);
    // one caller per viewer: the value the schema's policies see as `viewer`
    anyone = RayfoldTest.of(server);
    customer = anyone.signedInAs(Map.of("id", "u1", "role", "customer"));
    staff = anyone.signedInAs(Map.of("id", "s1", "role", "staff"));
}
java
// no port is opened: the starter builds the server for any servlet application, and the default mock one is enough
@SpringBootTest
// a fresh application for every test, so no test sees another's purchases
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class BookshopUnitTest {
    // the server the starter built from the schema and the annotated resolvers
    @Autowired
    RayfoldServer server;

    @Autowired
    Store store;

    RayfoldTest anyone;
    RayfoldTest customer;
    RayfoldTest staff;

    @BeforeEach
    void callers() {
        // one caller per viewer: the value the schema's policies see as `viewer`
        anyone = RayfoldTest.of(server);
        customer = anyone.signedInAs(Map.of("id", "u1", "role", "customer"));
        staff = anyone.signedInAs(Map.of("id", "s1", "role", "staff"));
    }
  • In TypeScript the caller is the client your application already uses, over createLocalTransport instead of createFetchTransport.
  • On the JVM, RayfoldTest.of(server) calls as nobody and signedInAs(viewer) returns a caller for that viewer. Kotlin passes JsonObject arguments and gets JsonElement; Java passes maps and gets plain values.
  • In Spring Boot the server is the one the starter built, autowired. Spring Security is not involved: the test says who the viewer is.

A resolver ​

Ask with a shape, and assert on the whole result. The result is exactly what a client would receive, $type included.

ts
it("reads a book with the fields and the author it was asked for", async () => {
  const { anonymous, store } = bookshop();
  store.books.set("b4", { id: "b4", title: "Kindred", stock: 12, authorId: "a3", costPrice: "3.80" });
  store.authors.set("a3", { id: "a3", name: "Octavia E. Butler" });

  const book = await anonymous.query("book", { id: "b4" }, { shape: "{ id title stock author { name } }" });

  expect(book).toEqual({
    $type: "Book",
    id: "b4",
    title: "Kindred",
    stock: 12,
    author: { $type: "Author", name: "Octavia E. Butler" },
  });
  await expect(anonymous.query("book", { id: "b5" })).resolves.toBeNull();
});
kotlin
@Test
fun `book returns the shape it was asked for, with the author's name`() {
    val book = anyone.query("book", args("id" to "b1"), shape = "{ title stock author { name } }")
    assertEquals(
        json($$"""{"$type":"Book","title":"A Wizard of Earthsea","stock":3,"author":{"$type":"Author","name":"Ursula K. Le Guin"}}"""),
        book,
    )
    assertEquals(1, store.authorLookups.get())
}
java
@Test
void bookReturnsTheShapeItWasAskedForWithTheAuthorsName() {
    Map<String, Object> book = anyone.query("book", Map.of("id", "b1"), "{ title stock author { name } }");
    assertEquals(
        Map.of("$type", "Book", "title", "A Wizard of Earthsea", "stock", 3L,
            "author", Map.of("$type", "Author", "name", "Ursula K. Le Guin")),
        book);
    assertEquals(1, store.authorLookups());
}
java
@Test
void bookReturnsTheShapeItWasAskedForWithTheAuthorsName() {
    Map<String, Object> book = anyone.query("book", Map.of("id", "b1"), "{ title stock author { name } }");
    assertThat(book).isEqualTo(Map.of("$type", "Book", "title", "A Wizard of Earthsea", "stock", 3L,
        "author", Map.of("$type", "Author", "name", "Ursula K. Le Guin")));
    assertThat(store.authorLookups()).isEqualTo(1);
}

A policy ​

A policy test has two halves: the caller the rule refuses, with the exact error code, and the caller it allows. Without the second half, a rule that refuses everyone would pass. Assert on the store too: a refused command must have changed nothing.

ts
it("lets staff restock, and refuses a customer and someone who is not signed in", async () => {
  const { anonymous, customer, staff, store } = bookshop();
  const restock = (client: RayfoldClient) => client.command("restock", { bookId: "b2", qty: 4 });

  await expect(restock(anonymous)).rejects.toMatchObject({ code: "unauthenticated" });
  await expect(restock(customer)).rejects.toMatchObject({ code: "permission_denied" });
  expect(store.books.get("b2")?.stock).toBe(0);

  await expect(restock(staff)).resolves.toMatchObject({ id: "b2", stock: 4 });
  expect(store.books.get("b2")?.stock).toBe(4);
});
kotlin
@Test
fun `a customer may not restock, and staff may`() {
    val refused = assertFailsWith<RayfoldException> {
        customer.command("restock", args("bookId" to "b2", "qty" to 5))
    }
    assertEquals(Code.PERMISSION_DENIED, refused.code)
    assertEquals(0, store.book("b2")?.stock)

    val restocked = staff.command("restock", args("bookId" to "b2", "qty" to 5), shape = "{ id stock }")
    assertEquals(json($$"""{"$type":"Book","id":"b2","stock":5}"""), restocked)
    assertEquals(5, store.book("b2")?.stock)
}
java
@Test
void aCustomerMayNotRestockAndStaffMay() {
    var refused = assertThrows(RayfoldException.class,
        () -> customer.command("restock", Map.of("bookId", "b2", "qty", 5)));
    assertEquals(Code.PERMISSION_DENIED, refused.getCode());
    assertEquals(0, store.book("b2").orElseThrow().stock());

    Map<String, Object> restocked = staff.command("restock", Map.of("bookId", "b2", "qty", 5), "{ id stock }");
    assertEquals(Map.of("$type", "Book", "id", "b2", "stock", 5L), restocked);
    assertEquals(5, store.book("b2").orElseThrow().stock());
}
java
@Test
void aCustomerMayNotRestockAndStaffMay() {
    var refused = catchThrowableOfType(RayfoldException.class,
        () -> customer.command("restock", Map.of("bookId", "b2", "qty", 5)));
    assertThat(refused.getCode()).isEqualTo(Code.PERMISSION_DENIED);
    assertThat(store.book("b2").orElseThrow().stock()).isZero();

    Map<String, Object> restocked = staff.command("restock", Map.of("bookId", "b2", "qty", 5), "{ id stock }");
    assertThat(restocked).isEqualTo(Map.of("$type", "Book", "id", "b2", "stock", 5L));
    assertThat(store.book("b2").orElseThrow().stock()).isEqualTo(5);
}

A declared error ​

An error the schema declares arrives by name, with its payload. In TypeScript the client rejects with it; on the JVM the caller throws the RayfoldException the server produced.

ts
it("refuses to sell more than there is, with OutOfStock and how many are left", async () => {
  const { customer, store } = bookshop();

  await expect(customer.command("buy", { bookId: "b1", qty: 5 })).rejects.toMatchObject({
    code: "domain",
    type: "OutOfStock",
    data: { bookId: "b1", available: 3 },
    message: "Only 3 left",
  });
  expect(store.books.get("b1")?.stock).toBe(3);

  // guard: what is there does sell
  await expect(customer.command("buy", { bookId: "b1", qty: 3 })).resolves.toMatchObject({ id: "b1", stock: 0 });
  expect(store.books.get("b1")?.stock).toBe(0);
});
kotlin
@Test
fun `buying more than the shelf holds fails with OutOfStock and changes nothing`() {
    val error = assertFailsWith<RayfoldException> {
        customer.command("buy", args("bookId" to "b1", "qty" to 5))
    }
    assertEquals(Code.DOMAIN, error.code)
    assertEquals("OutOfStock", error.type)
    assertEquals(json("""{"bookId":"b1","available":3}"""), error.data)
    assertEquals("Only 3 left of A Wizard of Earthsea", error.message)
    assertEquals(3, store.book("b1")?.stock)

    // guard: what the shelf holds can be bought
    customer.command("buy", args("bookId" to "b1", "qty" to 3))
    assertEquals(0, store.book("b1")?.stock)
}
java
@Test
void buyingMoreThanTheShelfHoldsFailsWithOutOfStockAndChangesNothing() {
    var error = assertThrows(RayfoldException.class,
        () -> customer.command("buy", Map.of("bookId", "b1", "qty", 5)));
    assertEquals(Code.DOMAIN, error.getCode());
    assertEquals("OutOfStock", error.getType());
    assertEquals(Map.of("bookId", "b1", "available", 3L), Rayfold.fromJson(error.getData()));
    assertEquals("Only 3 left of A Wizard of Earthsea", error.getMessage());
    assertEquals(3, store.book("b1").orElseThrow().stock());

    // guard: what the shelf holds can be bought
    customer.command("buy", Map.of("bookId", "b1", "qty", 3));
    assertEquals(0, store.book("b1").orElseThrow().stock());
}
java
@Test
void buyingMoreThanTheShelfHoldsFailsWithOutOfStockAndChangesNothing() {
    var error = catchThrowableOfType(RayfoldException.class,
        () -> customer.command("buy", Map.of("bookId", "b1", "qty", 5)));
    assertThat(error.getCode()).isEqualTo(Code.DOMAIN);
    assertThat(error.getType()).isEqualTo("OutOfStock");
    assertThat(Rayfold.fromJson(error.getData())).isEqualTo(Map.of("bookId", "b1", "available", 3L));
    assertThat(error.getMessage()).isEqualTo("Only 3 left of A Wizard of Earthsea");
    assertThat(store.book("b1").orElseThrow().stock()).isEqualTo(3);

    // guard: what the shelf holds can be bought
    customer.command("buy", Map.of("bookId", "b1", "qty", 3));
    assertThat(store.book("b1").orElseThrow().stock()).isZero();
}

A retried command ​

A command sent twice under one idempotency key runs once. Check what the command did (the stock, here), and check the other side too: another key is another purchase.

On the JVM a command without a key gets a fresh one, so a test passes a key only when the key is what it tests. frames(...) returns what the server sent as it sent it, which is where meta.replay is.

ts
it("runs a purchase once however often it is sent under one idempotency key, and again under another", async () => {
  const store = seed();
  const shop = resolvers(store);
  const buy = vi.fn(shop.Command!["buy"]!);
  const { customer } = bookshop({ resolvers: { ...shop, Command: { ...shop.Command, buy } } }, store);

  const first = await customer.command("buy", { bookId: "b1" }, { key: "order-1001-payment" });
  const again = await customer.command("buy", { bookId: "b1" }, { key: "order-1001-payment" });
  expect(buy).toHaveBeenCalledTimes(1);
  expect(again).toEqual(first);
  expect(store.books.get("b1")?.stock).toBe(2);

  await customer.command("buy", { bookId: "b1" }, { key: "order-1002-payment" });
  expect(buy).toHaveBeenCalledTimes(2);
  expect(store.books.get("b1")?.stock).toBe(1);
});
kotlin
@Test
fun `a purchase retried with the same key sells once`() {
    val purchase = args("bookId" to "b3", "qty" to 2)
    val key = "purchase-0001-first"
    val first = customer.command("buy", purchase, shape = "{ id stock }", key = key)
    assertEquals(json($$"""{"$type":"Book","id":"b3","stock":5}"""), first)

    // frames() returns what the server sent as it sent it, where command() returns the result alone
    val retry = customer.frames("buy", purchase, shape = "{ id stock }", key = key).single()
    assertEquals(first, retry["ok"])
    assertEquals(json("""{"cost":1,"replay":true}"""), retry["meta"])
    assertEquals(5, store.book("b3")?.stock)

    // guard: without a key of its own each call gets a fresh one, so this is a new purchase
    customer.command("buy", purchase)
    assertEquals(3, store.book("b3")?.stock)
}
java
@Test
void aPurchaseRetriedWithTheSameKeySellsOnce() {
    Map<String, Object> purchase = Map.of("bookId", "b3", "qty", 2);
    String key = "purchase-0001-first";
    Map<String, Object> first = customer.command("buy", purchase, "{ id stock }", key);
    assertEquals(Map.of("$type", "Book", "id", "b3", "stock", 5L), first);

    // frames() returns what the server sent as it sent it, where command() returns the result alone
    List<Map<String, Object>> retry = customer.frames("buy", purchase, "{ id stock }", key);
    assertEquals(1, retry.size(), "one frame, the replay");
    assertEquals(first, retry.getFirst().get("ok"));
    assertEquals(Map.of("cost", 1L, "replay", true), retry.getFirst().get("meta"));
    assertEquals(5, store.book("b3").orElseThrow().stock());

    // guard: without a key of its own each call gets a fresh one, so this is a new purchase
    customer.command("buy", purchase);
    assertEquals(3, store.book("b3").orElseThrow().stock());
}
java
@Test
void aPurchaseRetriedWithTheSameKeySellsOnce() {
    Map<String, Object> purchase = Map.of("bookId", "b3", "qty", 2);
    String key = "purchase-0001-first";
    Map<String, Object> first = customer.command("buy", purchase, "{ id stock }", key);
    assertThat(first).isEqualTo(Map.of("$type", "Book", "id", "b3", "stock", 5L));

    // frames() returns what the server sent as it sent it, where command() returns the result alone
    List<Map<String, Object>> retry = customer.frames("buy", purchase, "{ id stock }", key);
    assertThat(retry).as("one frame, the replay").hasSize(1);
    assertThat(retry.getFirst().get("ok")).isEqualTo(first);
    assertThat(retry.getFirst().get("meta")).isEqualTo(Map.of("cost", 1L, "replay", true));
    assertThat(store.book("b3").orElseThrow().stock()).isEqualTo(5);

    // guard: without a key of its own each call gets a fresh one, so this is a new purchase
    customer.command("buy", purchase);
    assertThat(store.book("b3").orElseThrow().stock()).isEqualTo(3);
}

A live query ​

A live query reports a value whenever what it shows changes. The test reads those values one at a time: each next() waits for one more, and fails when none comes.

  • TypeScript: collect from @rayfold/client/testing takes the callbacks of client.live or client.watch. next(label) waits 4 seconds at most, under the 5 seconds a test runner allows, so the failure you read is no value within 4000 ms: the restock: saw [...] and not the runner's timeout. For a stream, return client.stream(op, args, { signal }) with the signal collect passes as its third argument.
  • JVM: live(...) returns once the first result is there, and next() waits 5 seconds at most (within(ms) on the caller, or next(ms), changes that). close() returns once the server has its subscription back.
ts
it("a live query hears a restock someone else makes", async () => {
  const { customer, staff } = bookshop();
  const stock = collect<{ stock: number }>((next, fail) =>
    customer.live("book", { id: "b3" }, { shape: "{ id stock }" }, next, fail),
  );
  // each next() waits for one more value, for 4 seconds at most, and fails the test if none comes
  expect(await stock.next("the stock when the query opened")).toEqual({ $type: "Book", id: "b3", stock: 7 });

  await staff.command("restock", { bookId: "b3", qty: 2 });
  expect(await stock.next("the restock")).toEqual({ $type: "Book", id: "b3", stock: 9 });

  stock.stop();
  expect(stock.values.map((book) => book.stock)).toEqual([7, 9]);
});
kotlin
@Test
fun `a live query gets the new stock when someone buys`() {
    anyone.live("book", args("id" to "b3"), shape = "{ id stock }").use { book ->
        assertEquals(json($$"""{"$type":"Book","id":"b3","stock":7}"""), book.next())

        customer.command("buy", args("bookId" to "b3", "qty" to 2))
        // next() waits 5 s at most, and fails the test when nothing came
        assertEquals(json($$"""{"$type":"Book","id":"b3","stock":5}"""), book.next())
    }
    // closed, the query holds nothing on the server
    assertEquals(0, server.changes.size)
}
java
@Test
void aLiveQueryGetsTheNewStockWhenSomeoneBuys() {
    try (LiveQuery<Object> book = anyone.live("book", Map.of("id", "b3"), "{ id stock }")) {
        assertEquals(Map.of("$type", "Book", "id", "b3", "stock", 7L), book.next());

        customer.command("buy", Map.of("bookId", "b3", "qty", 2));
        // next() waits 5 s at most, and fails the test when nothing came
        assertEquals(Map.of("$type", "Book", "id", "b3", "stock", 5L), book.next());
    }
    // closed, the query holds nothing on the server
    assertEquals(0, server.getChanges().getSize());
}
java
@Test
void aLiveQueryGetsTheNewStockWhenSomeoneBuys() {
    try (LiveQuery<Object> book = anyone.live("book", Map.of("id", "b3"), "{ id stock }")) {
        assertThat(book.next()).isEqualTo(Map.of("$type", "Book", "id", "b3", "stock", 7L));

        customer.command("buy", Map.of("bookId", "b3", "qty", 2));
        // next() waits 5 s at most, and fails the test when nothing came
        assertThat(book.next()).isEqualTo(Map.of("$type", "Book", "id", "b3", "stock", 5L));
    }
    // closed, the query holds nothing on the server
    assertThat(server.getChanges().getSize()).isZero();
}

Time ​

The server reads the time from one clock. It uses it for now() in a policy, ctx.now in a resolver, the expiry of records in its default idempotency store, and its uptime. A store you give the server keeps its own clock. The clock is the system clock unless you give the server another. A test gives it one it can move, so a day passes in one line.

  • TypeScript: the now option of createRayfoldServer, epoch milliseconds.
  • Kotlin: RayfoldServer(..., now = ...), a function returning epoch milliseconds.
  • Java: .clock(...) on the builder, a java.time.Clock or a LongSupplier; the example's Bookshop.server(store, now) passes it on.
  • Spring Boot: the application's java.time.Clock bean, which the test replaces with one it sets.
ts
it("answers a repeated purchase from its record for 24 hours, by the server's clock", async () => {
  let now = Date.parse("2026-03-01T09:00:00Z");
  const { customer, store } = bookshop({ now: () => now });
  const order = { key: "order-1001-payment" };

  await customer.command("buy", { bookId: "b1" }, order);
  expect(store.books.get("b1")?.stock).toBe(2);

  now += 24 * 3_600_000 - 1; // a millisecond short of a day: still the first purchase's answer
  await customer.command("buy", { bookId: "b1" }, order);
  expect(store.books.get("b1")?.stock).toBe(2);

  now += 1; // a day old, the record has expired, so the key is a new purchase
  await customer.command("buy", { bookId: "b1" }, order);
  expect(store.books.get("b1")?.stock).toBe(1);
});
kotlin
@Test
fun `a retry is answered from the first purchase for a day, and is a new purchase after it`() {
    val day = 24 * 60 * 60 * 1000L
    val now = AtomicLong(0)
    // the server tells the time by the clock it is given, and the test moves it
    val timed = RayfoldServer(schema, resolvers(store), now = now::get)
    val customer = RayfoldTest.of(timed).signedInAs(mapOf("id" to "u1", "role" to "customer"))
    val purchase = args("bookId" to "b3", "qty" to 2)

    customer.command("buy", purchase, key = "purchase-0001-first")
    now.set(day - 1)
    customer.command("buy", purchase, key = "purchase-0001-first")
    assertEquals(5, store.book("b3")?.stock)

    now.set(day)
    customer.command("buy", purchase, key = "purchase-0001-first")
    assertEquals(3, store.book("b3")?.stock)
}
java
@Test
void aRetryIsAnsweredFromTheFirstPurchaseForADayAndIsANewPurchaseAfterIt() {
    long day = 24 * 60 * 60 * 1000L;
    AtomicLong now = new AtomicLong(0);
    // the server tells the time by the clock it is given, and the test moves it
    RayfoldServer timed = Bookshop.server(store, now::get);
    RayfoldTest customer = RayfoldTest.of(timed).signedInAs(Map.of("id", "u1", "role", "customer"));
    Map<String, Object> purchase = Map.of("bookId", "b3", "qty", 2);

    customer.command("buy", purchase, null, "purchase-0001-first");
    now.set(day - 1);
    customer.command("buy", purchase, null, "purchase-0001-first");
    assertEquals(5, store.book("b3").orElseThrow().stock());

    now.set(day);
    customer.command("buy", purchase, null, "purchase-0001-first");
    assertEquals(3, store.book("b3").orElseThrow().stock());
}
java
// the server tells the time by the application's Clock bean: here one that reads the time the test set
@TestConfiguration
static class TestClock {
    @Bean
    SetClock clock() { return new SetClock(); }
}

@Autowired
SetClock clock;

@Test
void aRetryIsAnsweredFromTheFirstPurchaseForADayAndIsANewPurchaseAfterIt() {
    long day = Duration.ofDays(1).toMillis();
    Map<String, Object> purchase = Map.of("bookId", "b3", "qty", 2);
    customer.command("buy", purchase, null, "purchase-0001-first");

    clock.millis.set(day - 1);
    customer.command("buy", purchase, null, "purchase-0001-first");
    assertThat(store.book("b3").orElseThrow().stock()).isEqualTo(5);

    clock.millis.set(day);
    customer.command("buy", purchase, null, "purchase-0001-first");
    assertThat(store.book("b3").orElseThrow().stock()).isEqualTo(3);
}

The client and its cache ​

What the client adds (the cache, optimistic updates and watches) is tested with the client itself over the in-process transport. The server answers for real, so a prediction is checked against what the server then says. In Kotlin that transport is LocalTransport(server) { viewer }; the test below wraps it to count the batches the client sends.

ts
it("shows a purchase before the shop has answered, and takes it back when the shop refuses", async () => {
  const { customer, staff } = bookshop();
  const stock = collect<{ stock: number }>((next, fail) =>
    customer.watch("book", { id: "b1" }, { shape: "{ id stock }" }, next, fail),
  );
  expect(await stock.next("the book")).toMatchObject({ stock: 3 });
  // sold out since, which this customer's client has not heard
  await staff.command("buy", { bookId: "b1", qty: 3 });

  const predicted = [{ set: "Book:b1", value: { stock: 2 } }];
  const refused = customer.command("buy", { bookId: "b1" }, { optimistic: predicted });
  expect(await stock.next("the predicted stock")).toMatchObject({ stock: 2 });
  await expect(refused).rejects.toMatchObject({ type: "OutOfStock", data: { bookId: "b1", available: 0 } });
  // back to what the server last told this client
  expect(await stock.next("the prediction taken back")).toMatchObject({ stock: 3 });

  stock.stop();
  expect(stock.values.map((book) => book.stock)).toEqual([3, 2, 3]);
});
kotlin
@Test
fun `the client app sees its purchase through the watch`(): Unit = runBlocking {
    // the app's own client, with the server where the network would be, counting the batches it sends
    val local = LocalTransport(server) { buildJsonObject { put("id", "u1"); put("role", "customer") } }
    val batches = AtomicInteger()
    val client = RayfoldClient(Transport { envelope, safe -> batches.incrementAndGet(); local.send(envelope, safe) })
    val lines = mutableListOf<String>()
    withTimeout(5_000) { buyOneCopy(client, lines::add) }
    assertEquals(
        listOf("A Wizard of Earthsea by Ursula K. Le Guin: 3 in stock", "watching: 3 in stock", "after buying one: 2 in stock"),
        lines,
    )
    assertEquals(2, store.book("b1")?.stock)
    // the book, the watch's first read, the purchase: the stock after it came back with the purchase
    assertEquals(3, batches.get())
}

React ​

Render the real component inside RayfoldProvider, with a client on the in-process transport. This test uses jsdom (// @vitest-environment jsdom) and React's own createRoot; the same setup works under Testing Library.

tsx
let unmount = () => {};
afterEach(() => {
  unmount();
  unmount = () => {};
});

/** Renders the app on a bookshop of its own. Nothing listens on a port: the client calls the server directly. */
function renderApp(viewer: Viewer | null = { id: "u1", role: "customer" }, store = seed()) {
  const server = createRayfoldServer({ schema, resolvers: resolvers(store) });
  const as = (who: Viewer | null) => new RayfoldClient({ transport: createLocalTransport(server, () => who) });
  const container = document.body.appendChild(document.createElement("div"));
  const root = createRoot(container);
  unmount = () => {
    flushSync(() => root.unmount());
    container.remove();
  };
  // flushSync: the first render is on the page when this returns, before any answer can have arrived
  const app = <RayfoldProvider client={as(viewer)}><App /></RayfoldProvider>;
  flushSync(() => root.render(app));
  return { container, store, as };
}

Wait for what the page shows, not for time. until looks again whenever the page changes, and fails with what the page says:

tsx
/** Resolves once `test` passes, looking again whenever the page changes. Fails after 4 s, saying what the page shows. */
function until(test: () => boolean, what: string): Promise<void> {
  return new Promise((resolve, reject) => {
    const settle = (done: () => void) => {
      observer.disconnect();
      clearTimeout(timer);
      done();
    };
    const observer = new MutationObserver(() => test() && settle(resolve));
    // under the test runner's own 5 s, so that this message is the one the failure shows
    const timer = setTimeout(() => {
      settle(() => reject(new Error(`still waiting for ${what}; the page says: ${document.body.textContent}`)));
    }, 4000);
    observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true });
    if (test()) settle(resolve);
  });
}

const row = (title: string) => [...document.querySelectorAll("li")].find((li) => li.textContent?.includes(title));
const stockOf = (id: string) => document.querySelector(`[data-stock="${id}"]`)?.textContent;

Then a test reads like what a person does:

tsx
it("says Loading..., then lists the books with their authors and stock", async () => {
  const { container } = renderApp();
  expect(container.textContent).toBe("BookshopLoading...");

  await until(() => stockOf("b3") === "7 in stock", "the last book's stock");
  expect([...container.querySelectorAll("li")].map((li) => li.textContent)).toEqual([
    "A Wizard of Earthsea by Ursula K. Le Guin 3 in stock Buy",
    "The Left Hand of Darkness by Ursula K. Le Guin 0 in stock Buy",
    "Dune by Frank Herbert 7 in stock Buy",
  ]);
});
tsx
it("Buy sells a copy, and the page shows the new stock of that book only", async () => {
  const { store } = renderApp();
  await until(() => stockOf("b1") === "3 in stock" && stockOf("b3") === "7 in stock", "the books' stock");

  row("A Wizard of Earthsea")?.querySelector("button")?.click();

  await until(() => stockOf("b1") === "2 in stock", "the stock after buying");
  expect(store.books.get("b1")?.stock).toBe(2);
  expect(stockOf("b3")).toBe("7 in stock");
});

Angular ​

provideRayfold(client) takes a client on the in-process transport, and injectQuery, injectLive and injectCommand run in an injection context as they do in a component. The signals they return are plain values, so this test needs neither a DOM nor TestBed.

In a component test with TestBed, provide the same client and wait with await fixture.whenStable(): a query and a live query keep the application unstable until their first answer, and a command while it runs.

ts
let injector: DestroyableInjector | undefined;
afterEach(() => injector?.destroy()); // ends what the test left subscribed

/** A bookshop of its own and an injector whose client calls it directly, as `viewer`. Nothing listens on a port. */
function start(viewer: Viewer | null = { id: "u1", role: "customer" }) {
  const { server, store } = createBookshop();
  const as = (who: Viewer | null) => new RayfoldClient({ transport: createLocalTransport(server, () => who) });
  const providing = Injector.create({ providers: provideRayfold(as(viewer)) });
  injector = providing;
  return { injector: providing, store, as };
}

To wait for a signal, follow it with createWatch from @angular/core/primitives/signals and hand its values to collect:

ts
/** What `read` returns, now and again each time a signal it reads changes. */
function following<T>(read: () => T): Collected<T> {
  return collect<T>((next) => {
    // an injector on its own runs no effects, so this follows the signals directly
    const watch = createWatch(() => next(read()), (dirty) => queueMicrotask(() => dirty.run()), false);
    watch.run();
    return () => watch.destroy();
  });
}
ts
it("injectQuery is loading from the moment it is injected, then holds the book", async () => {
  const { injector } = start();
  const book = runInInjectionContext(injector, () =>
    injectQuery<Book>("book", { id: "b1" }, { shape: "{ id title stock }" }),
  );
  expect([book.loading(), book.data(), book.error()]).toEqual([true, undefined, undefined]);

  const loading = following(() => book.loading());
  expect(await loading.next("the query in flight")).toBe(true);
  expect(await loading.next("the query settled")).toBe(false);
  loading.stop();

  expect(book.data()).toEqual({ $type: "Book", id: "b1", title: "A Wizard of Earthsea", stock: 3 });
  expect(book.error()).toBeUndefined();
});
ts
it("injectCommand runs a restock, and the query showing that book follows it", async () => {
  const { injector, store } = start({ id: "s1", role: "staff" });
  const [book, restock] = runInInjectionContext(injector, () => [
    injectQuery<Book>("book", { id: "b2" }, { shape: "{ id stock }" }),
    injectCommand<Book>("restock"),
  ]);
  const stock = following(() => book.data()?.stock);
  expect(await stock.next("the query in flight")).toBeUndefined();
  expect(await stock.next("the book")).toBe(0);

  const done = restock.run({ bookId: "b2", qty: 4 });
  expect(restock.running()).toBe(true);
  await done;
  expect([restock.running(), restock.data()?.stock, restock.error()]).toEqual([false, 4, undefined]);

  expect(await stock.next("the restocked book")).toBe(4);
  stock.stop();
  expect(store.books.get("b2")?.stock).toBe(4);
});
ts
it("injectLive follows a restock someone else makes", async () => {
  const { injector, as } = start();
  const book = runInInjectionContext(injector, () =>
    injectLive<Book>("book", { id: "b3" }, { shape: "{ id stock }" }),
  );
  const stock = following(() => book.data()?.stock);
  expect(await stock.next("the query opening")).toBeUndefined();
  expect(await stock.next("the stock when the query opened")).toBe(7);

  await as({ id: "s1", role: "staff" }).command("restock", { bookId: "b3", qty: 5 });

  expect(await stock.next("the restock")).toBe(12);
  stock.stop();
  expect(stock.values).toEqual([undefined, 7, 12]);
});

What to test over the network ​

Keep a few tests that go through the transport you deploy, because the transport decides these, not the server:

  • Who the caller is. That a token becomes the right viewer, and that a bad one is refused with 401 (Who can do what).
  • Browsers. Allowed origins and the CORS preflight.
  • REST routes. Paths, status codes and headers of @http bindings.
  • Caching. ETag, Cache-Control and 304 (Caching).

The bookshop examples have both kinds side by side: bookshop.unit.test.ts and bookshop.test.ts in TypeScript, BookshopUnitTest and the HTTP tests beside it in Kotlin, Java and Spring Boot.

Released under the Apache-2.0 license.