Skip to content

Commit 9313f9c

Browse files
committed
feat(shell): Mock mode for sh
I want to control the results from `sh` calls in order to test different paths through a script without changing my system configuration.
1 parent 7ee8f9c commit 9313f9c

4 files changed

Lines changed: 346 additions & 1 deletion

File tree

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,40 @@ await withYamlFile('example.yaml', (f) => {
102102
f.setIn(['foo', 'bar', 'baz'], 42);
103103
});
104104
```
105+
106+
## Test helpers
107+
108+
### `sh.mock()`
109+
110+
`sh` has a mock mode that allows you to control its output, which can be useful when testing scripts. Activate it using `sh.mock()`. This returns an object with methods to control `sh` behaviour. You can provide sequential mock results to return, either as default (ignoring the command being run) or matching to a specific command.
111+
112+
When you're done, use `sh.restore()` to stop mocking.
113+
114+
```ts
115+
// activate mock mode
116+
const mock = sh.mock();
117+
118+
// add some default mocks
119+
mock.returns({ stdout: 'first call' });
120+
mock.returns({ stdout: 'second call' });
121+
122+
// add some command-specific mocks
123+
mock.command('cat file.txt').returns({ stdout: 'file contents' });
124+
mock.command(/echo/).returns({ stdout: 'mock echo' });
125+
126+
await sh('some arbitrary command');
127+
// => 'first call'
128+
129+
await sh('some arbitrary command');
130+
// => 'second call'
131+
132+
await sh('echo "hi there"');
133+
// => 'mock echo'
134+
135+
await sh('cat file.txt');
136+
// => 'file contents'
137+
138+
// assert that all expected `sh` calls were made
139+
mock.assertDone();
140+
sh.restore();
141+
```

src/regex.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export function escapeRegex(string: string): string {
1616
/**
1717
* Turns a `string | RegExp` into a RegExp.
1818
*
19+
* If `value` is a string, the returned RegExp will match `value` anywhere in
20+
* the target string.
21+
*
1922
* @param value
2023
*/
2124
export function regex(value: string | RegExp): RegExp {
@@ -24,6 +27,20 @@ export function regex(value: string | RegExp): RegExp {
2427
: value;
2528
}
2629

30+
/**
31+
* Turns a `string | RegExp` into a RegExp.
32+
*
33+
* If `value` is a string, the returned RegExp is anchored so that it will only
34+
* match if the target string exactly equals `value`.
35+
*
36+
* @param value
37+
*/
38+
export function anchoredRegex(value: string | RegExp): RegExp {
39+
return typeof value === 'string'
40+
? new RegExp(`^${escapeRegex(value)}$`)
41+
: value;
42+
}
43+
2744
/**
2845
* Turns a search value (string or RegExp) into a global RegExp.
2946
*

src/shell.ts

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { ExecOptions, exec as execWithCallback } from 'child_process';
22
import { promisify } from 'util';
33

4+
import { escapeRegex } from './regex';
5+
46
/**
57
* Promisified version of `child_process.exec`.
68
*
@@ -21,6 +23,11 @@ import { promisify } from 'util';
2123
*/
2224
export const exec = promisify(execWithCallback);
2325

26+
/**
27+
* `exec` function used by `sh`. This is mutable to allow faking.
28+
*/
29+
let shExec: (command: string, options?: ExecOptions) => Promise<{ stdout: string; stderr: string; }> = exec;
30+
2431
/**
2532
* Options for `sh`.
2633
*/
@@ -47,14 +54,178 @@ export type ShOptions = ExecOptions & {
4754
* // => 'hello\n'
4855
* ```
4956
*
57+
* `sh` has a mock mode that allows you to control its output, which can be
58+
* useful when testing scripts. Activate it using `sh.mock()`.
59+
*
5060
* @param command The command to run.
5161
* @param options Options to be passed to `exec`.
5262
*/
5363
export async function sh(command: string, options?: ShOptions): Promise<string> {
54-
const result = await exec(command, options);
64+
const result = await shExec(command, options);
5565
let output = result.stdout.toString();
5666
if (options?.trim ?? true) {
5767
output = output.trim();
5868
}
5969
return output;
6070
}
71+
72+
export interface MockShCommandController {
73+
/**
74+
* Adds a new command-specific mock result.
75+
*
76+
* @param mock The mock result.
77+
* @param [mock.exitCode] The mock process exit code.
78+
* @param [mock.stdout] The mock stdout output.
79+
* @param [mock.stderr] The mock stderr output.
80+
*/
81+
returns(mock: { exitCode?: number, stdout?: string, stderr?: string }): MockShCommandController;
82+
}
83+
84+
export interface MockShController {
85+
/**
86+
* Adds a new default mock result.
87+
*
88+
* @param mock The mock result.
89+
* @param [mock.exitCode] The mock process exit code.
90+
* @param [mock.stdout] The mock stdout output.
91+
* @param [mock.stderr] The mock stderr output.
92+
*/
93+
returns(mock: { exitCode?: number, stdout?: string, stderr?: string }): MockShController;
94+
95+
/**
96+
* Adds a command matcher.
97+
*
98+
* @param command The command to match.
99+
*/
100+
command(command: string | RegExp): MockShCommandController;
101+
102+
/**
103+
* Asserts that a corresponding `sh` call was made for each mock.
104+
*
105+
* Throws if there are mocks that haven't been used.
106+
*/
107+
assertDone(): void;
108+
109+
/**
110+
* Removes all mocks.
111+
*
112+
* `sh` remains in mock mode. To restore it, use `sh.restore()`.
113+
*/
114+
reset(): void;
115+
}
116+
117+
/**
118+
* Start mocking `sh`.
119+
*
120+
* Returns an object with methods to control `sh` behaviour. You can provide
121+
* sequential mock results to return, either as default (ignoring the command
122+
* being run) or matching to a specific command.
123+
*
124+
* When you're done, use `sh.restore()` to stop mocking.
125+
*
126+
* ```ts
127+
* // activate mock mode
128+
* const mock = sh.mock();
129+
*
130+
* // add some default mocks
131+
* mock.returns({ stdout: 'first call' });
132+
* mock.returns({ stdout: 'second call' });
133+
*
134+
* // add some command-specific mocks
135+
* mock.command('cat file.txt').returns({ stdout: 'file contents' });
136+
* mock.command(/echo/).returns({ stdout: 'mock echo' });
137+
*
138+
* await sh('some arbitrary command');
139+
* // => 'first call'
140+
*
141+
* await sh('some arbitrary command');
142+
* // => 'second call'
143+
*
144+
* await sh('echo "hi there"');
145+
* // => 'mock echo'
146+
*
147+
* await sh('cat file.txt');
148+
* // => 'file contents'
149+
*
150+
* // assert that all expected `sh` calls were made
151+
* mock.assertDone();
152+
* sh.restore();
153+
* ```
154+
*/
155+
sh.mock = () => {
156+
if (shExec !== exec) {
157+
throw new Error('`sh` is already mocked');
158+
}
159+
160+
type Mock = { exitCode: number, stdout: string, stderr: string };
161+
const matchers: [RegExp, Mock[]][] = [];
162+
const defaults: Mock[] = [];
163+
164+
shExec = async (command) => {
165+
const match = matchers.find(([r, m]) => m.length > 0 && r.exec(command));
166+
const mock = match ? match[1].shift() : defaults.shift();
167+
if (!mock) {
168+
throw new Error(`No mock found for command: ${command}`);
169+
}
170+
if (mock.exitCode !== 0) {
171+
throw new Error(`Command failed: ${command}\n${mock.stderr}`);
172+
}
173+
return { stdout: mock.stdout, stderr: mock.stderr };
174+
};
175+
176+
const mockController: MockShController = {
177+
returns(mock: { exitCode?: number, stdout?: string, stderr?: string }) {
178+
const {
179+
exitCode = 0,
180+
stdout = '',
181+
stderr = '',
182+
} = mock;
183+
defaults.push({ exitCode, stdout, stderr });
184+
return mockController;
185+
},
186+
187+
command(command: string | RegExp) {
188+
const re = typeof command === 'string'
189+
? new RegExp(`^${escapeRegex(command)}$`)
190+
: command;
191+
const mocks: Mock[] = [];
192+
matchers.push([re, mocks]);
193+
194+
const cmdController: MockShCommandController = {
195+
returns(mock: { exitCode?: number, stdout?: string, stderr?: string }) {
196+
const {
197+
exitCode = 0,
198+
stdout = '',
199+
stderr = '',
200+
} = mock;
201+
mocks.push({ exitCode, stdout, stderr });
202+
return cmdController;
203+
},
204+
};
205+
return cmdController;
206+
},
207+
208+
assertDone() {
209+
if (
210+
defaults.length > 0
211+
|| matchers.some(([, unconsumed]) => unconsumed.length > 0)
212+
) {
213+
throw new Error('Some `sh` mocks were not used');
214+
}
215+
},
216+
217+
reset() {
218+
matchers.splice(0, matchers.length);
219+
defaults.splice(0, defaults.length);
220+
},
221+
};
222+
223+
return mockController;
224+
};
225+
226+
/**
227+
* Stop mocking `sh`.
228+
*/
229+
sh.restore = () => {
230+
shExec = exec;
231+
};

tests/shell.spec.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,123 @@ describe('sh', () => {
3636
});
3737
});
3838
});
39+
40+
describe('sh mock mode', () => {
41+
afterEach(() => sh.restore());
42+
43+
describe('the example in the JSDoc', () => {
44+
it('should work as demonstrated', async () => {
45+
const mock = sh.mock();
46+
47+
mock.returns({ stdout: 'First call' });
48+
mock.returns({ stdout: 'Second call' });
49+
50+
mock.command('cat file.txt').returns({ stdout: 'file contents' });
51+
mock.command(/echo/).returns({ stdout: 'mock echo!' });
52+
53+
let result = await sh('some arbitrary command');
54+
expect(result).to.equal('First call');
55+
56+
result = await sh('some arbitrary command');
57+
expect(result).to.equal('Second call');
58+
59+
result = await sh('echo "hi there"');
60+
expect(result).to.equal('mock echo!');
61+
62+
result = await sh('cat file.txt');
63+
expect(result).to.equal('file contents');
64+
65+
mock.assertDone();
66+
sh.restore();
67+
});
68+
});
69+
70+
describe('sh.mock', () => {
71+
it('should throw if called twice', () => {
72+
sh.mock();
73+
expect(() => sh.mock()).to.throw('`sh` is already mocked');
74+
});
75+
});
76+
77+
describe('default mocks', () => {
78+
it('should return the mock results in order', async () => {
79+
sh.mock()
80+
.returns({ stdout: 'first' })
81+
.returns({ stdout: 'second' });
82+
83+
let result = await sh('blah');
84+
expect(result).to.equal('first');
85+
86+
result = await sh('blah');
87+
expect(result).to.equal('second');
88+
});
89+
90+
it('should throw if `sh` is called without a mock', async () => {
91+
sh.mock();
92+
93+
await expect(sh('blah')).to.eventually.be.rejectedWith(
94+
'No mock found for command: blah',
95+
);
96+
});
97+
});
98+
99+
describe('matching mocks', () => {
100+
it('should return the mock results for the matching command', async () => {
101+
const mock = sh.mock();
102+
mock.command('a')
103+
.returns({ stdout: 'first a' })
104+
.returns({ stdout: 'second a' });
105+
mock.command('b')
106+
.returns({ stdout: 'first b' })
107+
.returns({ stdout: 'second b' });
108+
109+
let result = await sh('a');
110+
expect(result).to.equal('first a');
111+
112+
result = await sh('b');
113+
expect(result).to.equal('first b');
114+
115+
result = await sh('a');
116+
expect(result).to.equal('second a');
117+
118+
result = await sh('b');
119+
expect(result).to.equal('second b');
120+
});
121+
122+
it("should fall back to default mock if command doesn't match", async () => {
123+
sh.mock()
124+
.returns({ stdout: 'default' })
125+
.command('a').returns({ stdout: 'matched' });
126+
127+
const result = await sh('blah');
128+
expect(result).to.equal('default');
129+
});
130+
});
131+
132+
describe('assertDone', () => {
133+
it('should do nothing if all mocks were used', async () => {
134+
const mock = sh.mock().returns({ stdout: 'test' });
135+
await sh('blah');
136+
mock.assertDone();
137+
});
138+
139+
it('should throw if some mocks were not used', () => {
140+
const mock = sh.mock().returns({ stdout: 'test' });
141+
expect(() => mock.assertDone()).to.throw();
142+
});
143+
});
144+
145+
describe('reset', () => {
146+
it('should clear out any existing mocks', async () => {
147+
const mock = sh.mock();
148+
mock.returns({ stdout: 'test' });
149+
mock.command('blah').returns({ stdout: 'test' });
150+
151+
mock.reset();
152+
mock.returns({ stdout: 'new' });
153+
154+
const result = await sh('blah');
155+
expect(result).to.equal('new');
156+
});
157+
});
158+
});

0 commit comments

Comments
 (0)