@fgaudo/effect-tea - v0.7.2
    Preparing search index...

    Testing

    Most of a program is pure, so most of its tests need no runtime. The tests of the timer, in examples/timer/timer.test.ts, use bun:test, fast-check for states, and the tools of @fgaudo/effect-tea/testing:

    import { describe, expect, test } from 'bun:test'
    import { InvariantDefect, isRejected, subscriptionsOf } from '@fgaudo/effect-tea'
    import { applyAll, dispatchViolations, rehearse } from '@fgaudo/effect-tea/testing'
    import * as Effect from 'effect/Effect'
    import * as HashMap from 'effect/HashMap'
    import * as Opt from 'effect/Option'
    import * as fc from 'fast-check'
    import { messagesSentBy } from '../snabbdom.ts'
    import { Bell, Command, Message, type Model, Phase, type State, timer, update } from './timer.ts'
    import { timerView } from './view.ts'

    The tests check their rules for any state the timer can be in, so they start from an arbitrary that produces only valid states, every phase among them, and from the messages a user could send:

    // Any valid state: every phase, with no more seconds left than the minutes set.
    const state: fc.Arbitrary<State> = fc.integer({ min: 1, max: 120 }).chain((minutes) =>
    fc.record({
    minutes: fc.constant(minutes),
    phase: fc.oneof(
    fc.constant(Phase.Setting()),
    fc.integer({ min: 1, max: minutes * 60 }).map((secondsLeft) => Phase.Running({ secondsLeft })),
    fc.integer({ min: 1, max: minutes * 60 }).map((secondsLeft) => Phase.Paused({ secondsLeft })),
    fc.constant(Phase.Finished()),
    ),
    })
    )

    // What a user could ask of a timer: everything but the ticks, which only the clock sends.
    const userMessages = [
    Message.MoreClicked(),
    Message.LessClicked(),
    Message.StartClicked(),
    Message.PauseClicked(),
    Message.ResumeClicked(),
    Message.ResetClicked(),
    ]

    const setting = timer.init.state

    applyAll(update) applies messages one after the other, and fails the test when update rejects one of them. A rejection is asserted with isRejected, a defect with InvariantDefect, and the commands by value, since they are data:

    describe('update', () => {
    test('sets the minutes, never below one', () => {
    expect(applyAll(update)([Message.LessClicked()], { ...setting, minutes: 2 }).state.minutes).toBe(1)
    expect(isRejected(update(Message.LessClicked())({ ...setting, minutes: 1 }))).toBe(true)
    })

    test('starts from the minutes set, and rings once at zero', () => {
    const { state, commands } = applyAll(update)(
    [Message.StartClicked(), ...Array.from({ length: 60 }, () => Message.Ticked())],
    { ...setting, minutes: 1 },
    )
    expect(state.phase).toEqual(Phase.Finished())
    expect(commands).toEqual([Command.Ring()])
    })

    test('rejects a second start, and changing the minutes while running', () => {
    const { state } = applyAll(update)([Message.StartClicked()], setting)
    expect(isRejected(update(Message.StartClicked())(state))).toBe(true)
    expect(isRejected(update(Message.MoreClicked())(state))).toBe(true)
    })

    // A tick outside the running phase cannot arrive: the subscription stops with it.
    test('dies on a tick while not running', () => {
    fc.assert(fc.property(state, (before) => {
    fc.pre(before.phase._tag !== 'Running')
    expect(() => update(Message.Ticked())(before)).toThrow(InvariantDefect)
    }))
    })
    })

    subscriptionsOf(timer)(state) says what the program listens to in a state:

    test('ticks exactly while running', () => {
    fc.assert(fc.property(state, (before) => {
    expect(HashMap.has(subscriptionsOf(timer)(before), 'ticks')).toBe(before.phase._tag === 'Running')
    }))
    })

    The run does not check that the view offers only what update accepts. dispatchViolations does, for the real view in any state: each message the view sends must be accepted, and act once when sent twice unless it is meant to repeat (repeatable), and each of the messages a user could send that update accepts must be among those the view sends. messagesSentBy comes from the bridge: it draws the view and fires every handler.

    // For any state, the real view sends only what `update` accepts, and all of it. "+" and "−" are meant to repeat.
    test('the view sends what update accepts, all of it, and acts once when sent twice', () => {
    const violations = dispatchViolations(update, {
    repeatable: (message) => message._tag === 'MoreClicked' || message._tag === 'LessClicked',
    })
    fc.assert(fc.property(state, (before) => {
    const sent = messagesSentBy<Model, Message>(timerView, timer.makeModel(before))
    expect(violations(before, sent, userMessages)).toEqual([])
    }))
    })

    rehearse acts on the program as a user does, through what the current model shows, but nothing runs by itself: the test says when a subscription's message is heard (hear) and when a command runs (run), through the program's runCommand, with the services the test provides. It fails when a command is still pending at the end, since its answer would still arrive.

    // The last second, step by step: the test says when the tick is heard and when the bell rings.
    test('rings when the time is up, and sets again on "Again"', async () => {
    let rings = 0
    const bell = Bell.of({ ring: Effect.sync(() => rings++) })
    const finished = await Effect.runPromise(
    rehearse(
    timer,
    Effect.fnUntraced(function*(rehearsal) {
    yield* rehearsal.hear('ticks', Message.Ticked())
    const finished = (yield* rehearsal.model)._tag
    expect(rings).toBe(0)
    yield* rehearsal.run((command) => command._tag === 'Ring')
    yield* rehearsal.send((model) => model._tag === 'Finished' ? Opt.some(model.reset) : Opt.none())
    expect((yield* rehearsal.model)._tag).toBe('Setting')
    return finished
    }),
    { from: { state: { minutes: 1, phase: Phase.Running({ secondsLeft: 1 }) } } },
    ).pipe(Effect.provideService(Bell, bell)),
    )
    expect(finished).toBe('Finished')
    expect(rings).toBe(1)
    })

    A parent is a program like any other: timers.update, timers.makeModel and timers.init are tested with the same tools. Its messages for a child are wrapped, { _tag: 'Timer', id, message }, and so are its commands and the keys of its children's subscriptions, which is what subscriptionsOf, rehearsal.run and rehearsal.hear see. The tests of examples/timer/timers.test.ts name its messages:

    const added: Message = { _tag: 'AddClicked' }
    const removed = (id: number): Message => ({ _tag: 'RemoveClicked', id })
    const to = (id: number, message: Timer.Message): Message => ({ _tag: 'Timer', id, message })

    and check its own rules, each timer's state, and the keys of the timers that tick:

    test('each timer keeps its own state', () => {
    const { state } = applyAll(timers.update)([added, to(2, Timer.Message.StartClicked())], timers.init.state)
    expect(state.entries.map((entry) => entry.timer.phase._tag)).toEqual(['Setting', 'Running'])
    })

    test('a removed timer is gone, and a second click on "Remove" is rejected', () => {
    const { state } = applyAll(timers.update)([added, removed(1)], timers.init.state)
    expect(state.entries.map((entry) => entry.id)).toEqual([2])
    expect(isRejected(timers.update(removed(1))(state))).toBe(true)
    expect(isRejected(timers.update(to(1, Timer.Message.StartClicked()))(state))).toBe(true)
    })

    test('only running timers tick, each under its own key', () => {
    const { state } = applyAll(timers.update)([added, to(2, Timer.Message.StartClicked())], timers.init.state)
    expect(Array.from(HashMap.keys(subscriptionsOf(timers)(state)))).toEqual([['Timer', 2, 'ticks']])
    })

    dispatchViolations checks the parent's view the same way, with candidates for every timer, each wrapped with its id.

    • The other tools, simulate for a flow with its commands answered at once and drive for the running program, are in testing, with what each fails on and what it ignores.
    • Guarantees says what the library promises, and what is left to the program.
    • The other examples show what the timer does not: an input the user types into, a failure as a message, events between a parent and its children.