Browse documentation

TUTORIAL / 10

Prove the application works

Test domain identity, application transitions, and real browser events using the code you built.

The interactive example loads in the browser.

Test the three behaviors we relied on: IDs choose the right item, creation preserves rejected drafts, and editing preserves the right row. These tests import the application you just built.

Keep the domain and application checks

The domain test already checks that toggling one ID preserves the other item’s identity and leaves the input unchanged. The creation test checks blank rejection, successful insertion, and preserving the draft when the factory fails. Their complete files are included below.

The factory has no expected-error channel, so its failure test supplies a defect with Effect.die. That checks that failure neither partially commits nor loses the draft; it does not introduce a new expected rejection type.

Test the real view in src/presentation.test.ts

The test mounts TodoApp with controlled services. Fork the renderer in the test Scope and await its first emission through a Deferred. The renderer stays subscribed while the test sends events:

const ready = yield* Deferred.make<void>();

yield* render(TodoApp, host).pipe(
  Fx.observe(() => Deferred.succeed(ready, undefined)),
  Effect.scoped,
  Effect.forkScoped,
);
yield* Deferred.await(ready);

The event helpers reproduce the events our handlers consume:

// Assigning .value alone does not emit the input event our application observes.
const type = (input: HTMLInputElement, value: string) => {
  input.value = value;
  input.dispatchEvent(new Event("input", { bubbles: true }));
};
const submit = () =>
  host
    .querySelector(".new-todo")!
    .dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));

Changing .value alone would not notify the application. After the first submission, retain its row. Prepend another todo and check that the retained row merely moved:

const draft = host.querySelector<HTMLInputElement>(".new-todo")!;
type(draft, "Same title");
submit();

await vi.waitFor(() => expect(host.querySelectorAll(".todo-list > li")).toHaveLength(1));

// Retain the node: equal labels cannot prove that keyed identity survived.
const original = host.querySelector(".todo-list > li")!;

type(draft, "Same title");
submit();

await vi.waitFor(() => expect(host.querySelectorAll(".todo-list > li")).toHaveLength(2));
expect(host.querySelectorAll(".todo-list > li")[1]).toBe(original);

Now edit that row and press Escape:

original.querySelector(".view label")!.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
await vi.waitFor(() => expect(original.classList.contains("editing")).toBe(true));

const edit = original.querySelector<HTMLInputElement>(".edit")!;
type(edit, "Uncommitted text");
edit.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));

await vi.waitFor(() => expect(original.classList.contains("editing")).toBe(false));
expect(original.querySelector(".view label")?.textContent).toBe("Same title");
expect(host.querySelectorAll(".todo-list > li")[1]).toBe(original);

The editor disappears, its abandoned text never becomes committed text, and the same row remains. vi.waitFor waits for these observable results instead of guessing how long rendering takes. The full test uses an Effect Scope for renderer cleanup and finally to remove its host.

Run the complete tests

Copy the three test files below into src, then run:

npm install --save-dev vitest happy-dom

npx vitest run src/domain.test.ts src/application.test.ts src/presentation.test.ts

The domain and application tests run without a DOM environment. The presentation file’s first line selects Happy DOM. Use a real browser for focus, layout, and keyboard usability, and for the assembled create → edit → complete → filter → clear → reload flow. The completed preview uses the example’s own storage key and service implementation.

Continue with testing Typed systems for controlled time and lifetimes, or building UI components for the same techniques applied to another interaction.

Complete files

Keep the files from the previous step and replace or add these. Each full file is the source used by this milestone; the excerpts above select lines from it.

src/domain.test.ts
import { DateTime } from "effect";
import { describe, expect, it } from "vitest";
import { TodoId, type TodoList, toggleCompleted } from "./domain.js";

describe("Todo domain", () => {
  it("toggles only the requested Todo", () => {
    const timestamp = DateTime.makeUnsafe("2026-01-01T00:00:00Z");
    const first = TodoId.make("first");

    // Matching titles force this test to distinguish items by ID.
    const todos: TodoList = [
      { id: first, text: "Learn Typed", completed: false, timestamp },
      { id: TodoId.make("second"), text: "Learn Typed", completed: false, timestamp },
    ];

    const next = toggleCompleted(first)(todos);

    expect(next.map(({ completed }) => completed)).toEqual([true, false]);
    expect(todos.map(({ completed }) => completed)).toEqual([false, false]);
    expect(next[1]).toBe(todos[1]);
    expect(toggleCompleted(first)([])).toEqual([]);
  });
});
src/application.test.ts
import { DateTime, Effect, Exit, Layer } from "effect";
import { RefSubject } from "@typed/fx";
import { describe, expect, it } from "vitest";
import * as App from "./application.js";
import * as Domain from "./domain.js";

const timestamp = DateTime.makeUnsafe("2026-01-01T00:00:00Z");

const model = Layer.mergeAll(
  App.TodoList.make([]),
  App.TodoText.make(""),
  App.FilterState.make("all"),
);

describe("createTodo", () => {
  it("ignores blank drafts and commits the entered text before clearing the input", () => {
    // A blank draft must not reach the factory, even if it leaves the list unchanged.
    let calls = 0;
    const create = Layer.succeed(App.CreateTodo, (text) =>
      Effect.sync((): Domain.Todo => ({
        id: Domain.TodoId.make(`todo-${++calls}`),
        text,
        completed: false,
        timestamp,
      })),
    );

    return Effect.gen(function* () {
      yield* RefSubject.set(App.TodoText, "   ");
      yield* App.createTodo;

      expect(calls).toBe(0);
      expect(yield* App.TodoList).toEqual([]);
      expect(yield* App.TodoText).toBe("   ");

      yield* RefSubject.set(App.TodoText, "  Learn Typed  ");
      yield* App.createTodo;

      expect(calls).toBe(1);
      expect(yield* App.TodoList).toEqual([
        { id: Domain.TodoId.make("todo-1"), text: "  Learn Typed  ", completed: false, timestamp },
      ]);
      expect(yield* App.TodoText).toBe("");
      expect(yield* App.ActiveCount).toBe(1);
    }).pipe(Effect.provide([model, create]), Effect.scoped, Effect.runPromise);
  });

  it("preserves the draft when the factory cannot create an item", () =>
    Effect.gen(function* () {
      yield* RefSubject.set(App.TodoText, "Keep this draft");

      const exit = yield* Effect.exit(App.createTodo);

      expect(Exit.isFailure(exit)).toBe(true);
      expect(yield* App.TodoList).toEqual([]);
      expect(yield* App.TodoText).toBe("Keep this draft");
    }).pipe(
      Effect.provide([
        model,
        Layer.succeed(App.CreateTodo, () => Effect.die("ID factory unavailable")),
      ]),
      Effect.scoped,
      Effect.runPromise,
    ));
});
src/presentation.test.ts
// @vitest-environment happy-dom
import { DateTime, Deferred, Effect, Layer } from "effect";
import { Fx } from "@typed/fx";
import { ServerRouter } from "@typed/router";
import { DomRenderTemplate, render } from "@typed/template";
import { expect, it, vi } from "vitest";
import * as App from "./application.js";
import * as Domain from "./domain.js";
import { TodoApp } from "./presentation.js";

it("keeps the keyed row and discards its edit draft on Escape", async () => {
  const host = document.createElement("div");
  document.body.append(host);

  let nextId = 0;
  const services = Layer.mergeAll(
    App.TodoList.make([]),
    App.TodoText.make(""),
    App.FilterState.make("all"),
    ServerRouter({ url: "https://tutorial.local/" }),
    Layer.succeed(App.CreateTodo, (text) =>
      Effect.sync((): Domain.Todo => ({
        id: Domain.TodoId.make(`todo-${++nextId}`),
        text,
        completed: false,
        timestamp: DateTime.makeUnsafe("2026-01-01T00:00:00Z"),
      })),
    ),
  );

  // Assigning .value alone does not emit the input event our application observes.
  const type = (input: HTMLInputElement, value: string) => {
    input.value = value;
    input.dispatchEvent(new Event("input", { bubbles: true }));
  };
  const submit = () =>
    host
      .querySelector(".new-todo")!
      .dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));

  try {
    await Effect.gen(function* () {
      const ready = yield* Deferred.make<void>();

      yield* render(TodoApp, host).pipe(
        Fx.observe(() => Deferred.succeed(ready, undefined)),
        Effect.scoped,
        Effect.forkScoped,
      );
      yield* Deferred.await(ready);

      yield* Effect.promise(async () => {
        const draft = host.querySelector<HTMLInputElement>(".new-todo")!;
        type(draft, "Same title");
        submit();

        await vi.waitFor(() => expect(host.querySelectorAll(".todo-list > li")).toHaveLength(1));

        // Retain the node: equal labels cannot prove that keyed identity survived.
        const original = host.querySelector(".todo-list > li")!;

        type(draft, "Same title");
        submit();

        await vi.waitFor(() => expect(host.querySelectorAll(".todo-list > li")).toHaveLength(2));
        expect(host.querySelectorAll(".todo-list > li")[1]).toBe(original);

        original.querySelector(".view label")!.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
        await vi.waitFor(() => expect(original.classList.contains("editing")).toBe(true));

        const edit = original.querySelector<HTMLInputElement>(".edit")!;
        type(edit, "Uncommitted text");
        edit.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));

        await vi.waitFor(() => expect(original.classList.contains("editing")).toBe(false));
        expect(original.querySelector(".view label")?.textContent).toBe("Same title");
        expect(host.querySelectorAll(".todo-list > li")[1]).toBe(original);
      });
    }).pipe(
      Effect.provide([services, DomRenderTemplate.using(document)]),
      Effect.scoped,
      Effect.runPromise,
    );
  } finally {
    host.remove();
  }
});
src/infrastructure.ts
import { DateTime, Effect, Layer, Context, Schema } from "effect";
import { Fx } from "@typed/fx";
import * as Router from "@typed/router";
import * as App from "./application.js";
import * as Domain from "./domain.js";

const TODOS_STORAGE_KEY = `@typed/todomvc/todos`;

const TodoListJson = Schema.fromJsonString(Schema.toCodecJson(Domain.TodoList));
const decodeTodoList = Schema.decodeEffect(TodoListJson);
const encodeTodoList = Schema.encodeEffect(TodoListJson);

class Todos extends Context.Service<
  Todos,
  {
    readonly load: Effect.Effect<Domain.TodoList, unknown>;
    readonly save: (todos: Domain.TodoList) => Effect.Effect<void, unknown>;
  }
>()("TodosService") {
  static readonly get = Todos.pipe(
    Effect.flatMap((service) => service.load),
    Effect.catchCause(() => Effect.succeed([])),
  );

  static readonly set = (todos: Domain.TodoList) =>
    Effect.flatMap(Todos, (service) => service.save(todos)).pipe(
      Effect.catchCause((cause) =>
        Effect.logError("Failed to write todos to key value store", cause),
      ),
    );

  static readonly replicateToStorage = App.TodoList.pipe(Fx.observeLayer(Todos.set));

  static readonly local = Layer.succeed(Todos, {
    load: Effect.try(() => localStorage.getItem(TODOS_STORAGE_KEY)).pipe(
      Effect.flatMap((value) =>
        value === null ? Effect.succeed<Domain.TodoList>([]) : decodeTodoList(value),
      ),
    ),
    save: (todos) =>
      encodeTodoList(todos).pipe(
        Effect.flatMap((value) => Effect.try(() => localStorage.setItem(TODOS_STORAGE_KEY, value))),
      ),
  });
}

const FilterState = Router.match(Router.Slash, "all")
  .match(Router.Parse("active"), "active")
  .match(Router.Parse("completed"), "completed")
  .redirectTo("/")
  .pipe(
    Fx.catchCause(() => Fx.succeed("all" as const)),
  );

const Model = Layer.mergeAll(
  App.TodoList.make(Todos.get),
  App.FilterState.make(FilterState),
  App.TodoText.make(""),
);

const CreateTodo = Layer.sync(
  App.CreateTodo,
  () => (text: string) =>
    Effect.sync((): Domain.Todo => ({
      id: Domain.TodoId.make(crypto.randomUUID()),
      text,
      completed: false,
      timestamp: DateTime.makeUnsafe(new Date()),
    })),
);

export const makeServices = (router = Router.BrowserRouter()) =>
  Layer.mergeAll(CreateTodo, Todos.replicateToStorage).pipe(
    Layer.provideMerge(Model),
    Layer.provideMerge([Todos.local, router]),
  );

export const Services = makeServices();
Changes from Assemble the application1 file
src/application.test.ts
BeforeAfterChangeCode
… 8 unchanged lines …
99Unchangedconst model = Layer.mergeAll(
1010Unchanged App.TodoList.make([]),
1111Unchanged App.TodoText.make(""),
12Added App.FilterState.make("all"),
1213Unchanged);
1314Unchanged
1415Unchangeddescribe("createTodo", () => {
… 25 unchanged lines …
4041Unchanged { id: Domain.TodoId.make("todo-1"), text: " Learn Typed ", completed: false, timestamp },
4142Unchanged ]);
4243Unchanged expect(yield* App.TodoText).toBe("");
44Added expect(yield* App.ActiveCount).toBe(1);
4345Unchanged }).pipe(Effect.provide([model, create]), Effect.scoped, Effect.runPromise);
4446Unchanged });
4547Unchanged
… 18 unchanged lines …