Skip to content

Latest commit

 

History

History
229 lines (175 loc) · 6.01 KB

File metadata and controls

229 lines (175 loc) · 6.01 KB
category Advanced
alias testing
title Testing
description Strategies for testing Tagix applications

Testing

Unit Testing Actions

Test actions in isolation:

import { describe, it, expect } from "vitest";
import { createAction } from "tagix";

const CounterState = taggedEnum({
  Idle: { value: 0 },
  Ready: { value: 0 },
});

const increment = createAction<{ amount: number }, typeof CounterState.State>("Increment")
  .withPayload({ amount: 1 })
  .withState((s, p) => ({
    ...s,
    value: s.value + p.amount,
  }));

describe("Actions", () => {
  it("should increment value", () => {
    const state = CounterState.Idle({ value: 0 });
    const nextState = increment.handler(state, { amount: 5 });
    expect(nextState.value).toBe(5);
  });

  it("should handle negative amounts", () => {
    const state = CounterState.Ready({ value: 10 });
    const nextState = increment.handler(state, { amount: -3 });
    expect(nextState.value).toBe(7);
  });
});

Testing Stores

describe("Store", () => {
  it("should dispatch action", () => {
    const store = createStore(CounterState.Idle({ value: 0 }), CounterState);

    store.register("Increment", increment);
    store.dispatch("tagix/action/Increment", { amount: 5 });

    const state = store.stateValue as Extract<typeof CounterState.State, { _tag: "Ready" }>;
    expect(state.value).toBe(5);
  });

  it("should notify subscribers", () => {
    const store = createStore(CounterState.Idle({ value: 0 }), CounterState);

    let callCount = 0;
    const unsubscribe = store.subscribe(() => {
      callCount++;
    });

    store.dispatch("tagix/action/Increment", { amount: 1 });
    expect(callCount).toBe(1);

    unsubscribe();
    store.dispatch("tagix/action/Increment", { amount: 1 });
    expect(callCount).toBe(1);
  });
});

Testing Async Actions

describe("Async Actions", () => {
  it("should handle async flow", async () => {
    const store = createStore(CounterState.Idle({ value: 0 }), CounterState);

    const asyncIncrement = createAsyncAction<{ amount: number }, typeof CounterState.State, number>(
      "AsyncIncrement"
    )
      .state((s) => ({ ...s, _tag: "Ready" }))
      .effect(async (p) => {
        await new Promise((r) => setTimeout(r, 10));
        return p.amount;
      })
      .onSuccess((s, result) => ({ ...s, value: result }));

    store.register("AsyncIncrement", asyncIncrement);

    await store.dispatch(asyncIncrement, { amount: 42 });

    const state = store.stateValue as Extract<typeof CounterState.State, { _tag: "Ready" }>;
    expect(state.value).toBe(42);
  });

  it("should handle errors", async () => {
    const store = createStore(CounterState.Idle({ value: 0 }), CounterState);

    const failingAction = createAsyncAction<void, typeof CounterState.State, never>("Failing")
      .state((s) => ({ ...s, _tag: "Ready" }))
      .effect(async () => {
        throw new Error("Test error");
      })
      .onSuccess((s) => s)
      .onError((s, error) => ({ ...s, _tag: "Idle" }));

    store.register("Failing", failingAction);
    await store.dispatch(failingAction);

    expect(store.stateValue._tag).toBe("Idle");
  });
});

Forking for Testing

Isolate tests with forks:

describe("Fork Testing", () => {
  it("should not affect main store", () => {
    const mainStore = createStore(CounterState.Idle({ value: 0 }), CounterState);

    const fork = mainStore.fork();

    // Modify fork
    fork.dispatch("tagix/action/Increment", { amount: 100 });

    // Verify fork changed
    const forkState = fork.stateValue;
    expect(forkState.value).toBe(100);

    // Verify main store unchanged
    const mainState = mainStore.stateValue;
    expect(mainState.value).toBe(0);
  });
});

Selector Testing

describe("Selectors", () => {
  const selectors = {
    getValue: (state: typeof CounterState.State) => (state._tag === "Ready" ? state.value : 0),
  };

  it("should extract value from Ready state", () => {
    const state = CounterState.Ready({ value: 42 });
    expect(selectors.getValue(state)).toBe(42);
  });

  it("should return 0 for Idle state", () => {
    const state = CounterState.Idle({ value: 0 });
    expect(selectors.getValue(state)).toBe(0);
  });
});

Integration Testing

describe("Integration", () => {
  it("should handle complete workflow", async () => {
    const store = createStore(CounterState.Idle({ value: 0 }), CounterState);

    store.register("Increment", increment);

    // Initial state
    expect(store.stateValue.value).toBe(0);

    // Dispatch multiple actions
    store.dispatch("tagix/action/Increment", { amount: 5 });
    expect(store.stateValue.value).toBe(5);

    store.dispatch("tagix/action/Increment", { amount: 3 });
    expect(store.stateValue.value).toBe(8);

    // Subscribe and verify
    const states: number[] = [];
    const unsubscribe = store.subscribe((s) => {
      if (s._tag === "Ready") states.push(s.value);
    });

    store.dispatch("tagix/action/Increment", { amount: 2 });
    expect(states).toContain(10);

    unsubscribe();
  });
});

Mocking Dependencies

describe("With Mocks", () => {
  it("should use mocked API", async () => {
    const mockFetch = vi.fn().mockResolvedValue({ json: () => ({ id: 1, name: "Test" }) });

    const fetchUser = createAsyncAction<{ id: number }, UserState, User>("Fetch")
      .state((s) => ({ ...s, _tag: "Loading" }))
      .effect(async (p) => {
        const response = await mockFetch(`/api/users/${p.id}`);
        return response.json();
      })
      .onSuccess((s, user) => ({ ...s, _tag: "Loaded", user }))
      .onError((s, error) => ({ ...s, _tag: "Error", message: error.message }));

    const store = createStore(UserState.Idle({}), UserState);
    store.register("Fetch", fetchUser);

    await store.dispatch(fetchUser, { id: 1 });

    expect(mockFetch).toHaveBeenCalledWith("/api/users/1");
  });
});

See Also