From Discord: Exporting MockConsole and MockTerminal for Easier Testing
Summary
Elf Pavlik is learning Effect with a friend and has started working on a simple prompt-based CLI. They managed to implement minimal functionality and get a basic test to pass, found on their GitHub repository. They inquired if MockConsole and MockTerminal could be exported for easier testing. Tim Smart mentioned that these could potentially be exported but pointed out that their current testing mostly evaluates the CLI framework rather than the app logic. Maxwell Brown, noted as the CLI guru, agreed and considered the idea while suggesting a review of the API before making it public.
Key takeaways include:
- There's an interest in exporting
MockConsoleandMockTerminalfor more straightforward testing. - These modules are primarily used internally for testing the Effect CLI framework.
- There is recognition of the CLI's effectiveness, and potential improvements are under consideration for the future.
- Elf Pavlik suggested an enhancement in testing select prompts, proposing a method to choose select-prompt options by title rather than by navigating them with repeated key presses.
Maxwell Brown appreciated the suggestions and requested more concrete examples for the desired API change, to which Elf Pavlik responded with a use-case scenario.
Discord thread
https://discord.com/channels/795981131316985866/1294128342651371612
Example of proposed additional API:
const selectActionPrompt = Prompt.select({
message: 'Select action',
choices: [
{ title: 'Create social agent registration', value: Actions.createSocialAgentRegistration },
{ title: 'Create data registration', value: Actions.createDataRegistration }
]
})
// ...later
MockTerminal.selectTitle('Create data registration')
My team has run into the need to mock some default services. We've been using the pattern below with a queue that can be yielded back into the test.
mocks.test.ts
import { it } from 'bun:test';
import { Console, Effect } from 'effect';
import { Chunk, Context, Layer, Logger, Queue } from 'effect';
export type MockCollector<Message = unknown> = {
next: () => Effect.Effect<Message>;
count: () => Effect.Effect<number>;
drain: () => Effect.Effect<Message[]>;
};
export const makeCollector = <Message>(queue: Queue.Queue<Message>): MockCollector<Message> => ({
next: () => Queue.take(queue),
count: () => Queue.size(queue),
drain: () => Queue.takeAll(queue).pipe(Effect.map(Chunk.toArray)),
});
export type MockConsole = MockCollector<readonly unknown[]>;
export const MockConsole = (key = 'MockConsole') =>
Context.GenericTag<MockConsole, MockCollector<readonly unknown[]>>(key);
export type MockLogger<Message> = MockCollector<Message>;
export const MockLogger = <Message = unknown>(key = 'MockLogger') =>
Context.GenericTag<MockLogger<Message>, MockCollector<Message>>(key);
export const makeMockConsoleLayer = (key = 'MockConsole') =>
Effect.acquireRelease(Queue.unbounded<readonly unknown[]>(), Queue.shutdown).pipe(
Effect.map((queue) => {
const log = (...args: readonly unknown[]) =>
Effect.gen(function* () {
yield* Queue.offer(queue, args);
});
const unsafeLog = (...args: readonly unknown[]) => {
Queue.unsafeOffer(queue, args);
};
const console = Console.Console.of({
[Console.TypeId]: Console.TypeId,
log,
unsafe: {
log: unsafeLog,
assert: () => {},
clear: () => {},
count: () => Number.NaN,
countReset: () => {},
debug: () => {},
dir: () => {},
dirxml: () => {},
error: () => {},
group: () => {},
groupEnd: () => {},
groupCollapsed: () => {},
info: () => {},
table: () => {},
time: () => {},
timeEnd: () => {},
timeLog: () => {},
trace: () => {},
warn: () => {},
},
assert: () => Effect.void,
clear: Effect.void,
count: () => Effect.succeed(Number.NaN),
countReset: () => Effect.void,
debug: () => Effect.void,
dir: () => Effect.void,
dirxml: () => Effect.void,
error: () => Effect.void,
group: () => Effect.void,
groupEnd: Effect.void,
info: () => Effect.void,
table: () => Effect.void,
time: () => Effect.void,
timeEnd: () => Effect.void,
timeLog: () => Effect.void,
trace: () => Effect.void,
warn: () => Effect.void,
});
return Layer.merge(
Layer.succeed(MockConsole(key), makeCollector<readonly unknown[]>(queue)),
Console.setConsole(console),
);
}),
Layer.unwrapScoped,
);
export const makeMockLoggerLayer = <Message = unknown>(
logger: Logger.Logger<unknown, Message>,
options?: {
key?: string;
replace?: Logger.Logger<unknown, any>;
},
) =>
Effect.acquireRelease(Queue.unbounded<Message>(), Queue.shutdown).pipe(
Effect.map((queue) =>
Layer.merge(
Layer.succeed(MockLogger(options?.key ?? 'MockLogger'), makeCollector<Message>(queue)),
Logger.replace(
options?.replace ?? logger,
Logger.map(logger, (message) => {
Queue.unsafeOffer(queue, message);
}),
),
),
),
Layer.unwrapScoped,
);
it('should route console messages', () =>
Effect.gen(function* () {
const adapter = yield* MockConsole();
yield* Console.log('Hello, world!');
expect(yield* adapter.count()).toBe(1);
expect(yield* adapter.next()).toMatchObject(['Hello, world!']);
expect(yield* adapter.count()).toBe(0);
}).pipe(Effect.provide(makeMockConsoleLayer()), Effect.runPromise));
it('should route unsafe console messages (via Logger.withConsoleLog)', () =>
Effect.gen(function* () {
const adapter = yield* MockConsole();
yield* Effect.log('Hello, world!');
expect(yield* adapter.count()).toBe(1);
expect(yield* adapter.next()).toMatchObject([['Hello, world!']]);
expect(yield* adapter.count()).toBe(0);
}).pipe(
Effect.provide(makeMockConsoleLayer()),
Effect.provide(Logger.replace(Logger.defaultLogger, Logger.withConsoleLog(Logger.make(({ message }) => message)))),
Effect.runPromise,
));
it('should route log messages', () =>
Effect.gen(function* () {
const adapter = yield* MockLogger<{ message: string }>();
yield* Effect.log('Hello, world!');
expect(yield* adapter.count()).toBe(1);
expect(yield* adapter.next()).toMatchObject({ message: 'Hello, world!' });
expect(yield* adapter.count()).toBe(0);
}).pipe(
Effect.provide(makeMockLoggerLayer(Logger.structuredLogger, { replace: Logger.defaultLogger })),
Effect.runPromise,
));
I'm playing again with that prompt-based CLI and I'm wondering if there was a decision on exporting those mocks or not.
No decision as of yet - I think the primary concern is exposing a good API. I unfortunately don't have the capacity for this at the moment, but if someone else wants to take a stab and open a PR I can take some time to review.