Skip to content

Commit 0e0b537

Browse files
committed
Improve command test patterns with Sinon and integration tests
- Add sinon, @types/sinon, @oclif/test as SDK dev dependencies - Create stubParse helper for cleaner parse method mocking - Refactor 5 command test files to use Sinon instead of manual mocking - Add test fixture (test/fixtures/test-cli/) for integration testing - Add base-command.integration.test.ts with runCommand() tests - Update testing skill docs with new patterns The stubParse helper eliminates the brittle MockableXxxCommand type casting pattern. Integration tests exercise full command lifecycle through the oclif test utilities.
1 parent 21a8bf0 commit 0e0b537

12 files changed

Lines changed: 375 additions & 501 deletions

File tree

.claude/skills/testing/SKILL.md

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This skill covers project-specific testing patterns for the B2C CLI project.
1212
- **Test Runner**: Mocha
1313
- **Assertions**: Chai (property-based)
1414
- **HTTP Mocking**: MSW (Mock Service Worker)
15+
- **Stubbing/Mocking**: Sinon
1516
- **Code Coverage**: c8
1617
- **TypeScript**: tsx (native execution without compilation)
1718

@@ -120,14 +121,12 @@ const config = resolveConfig({}, {
120121
});
121122
```
122123

123-
In CLI command tests, mock the `credentials-file` flag:
124+
In CLI command tests, use the `stubParse` helper with the `credentials-file` flag:
124125

125126
```typescript
126-
cmd.parse = (async () => ({
127-
args: {},
128-
flags: {'credentials-file': '/dev/null'}, // Isolates from real ~/.mobify
129-
metadata: {},
130-
})) as typeof cmd.parse;
127+
import { stubParse } from '../helpers/stub-parse.js';
128+
129+
stubParse(command, {'credentials-file': '/dev/null'}); // Isolates from real ~/.mobify
131130
```
132131

133132
## Polling Tests (Avoid Fake Timers)
@@ -312,33 +311,34 @@ const customAuth = new MockAuthStrategy('custom-token');
312311

313312
Command tests should focus on **command-specific logic**, not trivial flag verification.
314313

315-
### Good Command Tests
314+
### Using the stubParse Helper
316315

317-
Test behavior that is specific to the command class:
316+
Use the `stubParse` helper from `test/helpers/stub-parse.js` to stub oclif's parse method. This handles the type casting needed for oclif's protected `parse` method:
318317

319318
```typescript
320-
describe('requireMrtCredentials', () => {
319+
import sinon from 'sinon';
320+
import { stubParse } from '../helpers/stub-parse.js';
321+
import { isolateConfig, restoreConfig } from '../helpers/config-isolation.js';
322+
323+
describe('cli/mrt-command', () => {
324+
afterEach(() => {
325+
sinon.restore();
326+
restoreConfig();
327+
});
328+
321329
it('throws error when no credentials', async () => {
322-
cmd.parse = (async () => ({
323-
args: {},
324-
flags: {'credentials-file': '/dev/null'},
325-
metadata: {},
326-
})) as typeof cmd.parse;
327-
328-
await cmd.init();
329-
let errorCalled = false;
330-
cmd.error = () => {
331-
errorCalled = true;
332-
throw new Error('Expected error');
333-
};
330+
stubParse(command, {'credentials-file': '/dev/null'});
331+
await command.init();
332+
333+
const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error'));
334334

335335
try {
336336
command.testRequireMrtCredentials();
337337
} catch {
338338
// Expected
339339
}
340340

341-
expect(errorCalled).to.be.true;
341+
expect(errorStub.called).to.be.true;
342342
});
343343
});
344344
```
@@ -350,12 +350,10 @@ Do not write tests that just verify flag values equal mocked values:
350350
```typescript
351351
// BAD - tests nothing (just verifies JavaScript assignment works)
352352
it('handles server flag', async () => {
353-
cmd.parse = (async () => ({
354-
flags: {server: 'test.demandware.net'},
355-
})) as typeof cmd.parse;
353+
stubParse(command, {server: 'test.demandware.net'});
356354

357-
await cmd.init();
358-
expect(cmd.flags.server).to.equal('test.demandware.net'); // Trivial!
355+
await command.init();
356+
expect(command.flags.server).to.equal('test.demandware.net'); // Trivial!
359357
});
360358
```
361359

@@ -372,6 +370,10 @@ it('handles server flag', async () => {
372370

373371
## Testing CLI Commands with oclif
374372

373+
### Integration Tests with runCommand
374+
375+
Use `@oclif/test`'s `runCommand()` for integration-style tests:
376+
375377
```typescript
376378
import { runCommand } from '@oclif/test';
377379
import { expect } from 'chai';
@@ -384,6 +386,18 @@ describe('ods list', () => {
384386
});
385387
```
386388

389+
### SDK Base Command Integration Tests
390+
391+
The SDK includes a test fixture at `test/fixtures/test-cli/` for integration testing base command behavior. See `test/cli/base-command.integration.test.ts` for examples.
392+
393+
### When to Use Each Approach
394+
395+
| Approach | Use For |
396+
|----------|---------|
397+
| Unit tests with `stubParse` | Testing protected method logic in isolation |
398+
| Integration tests with fixture | Testing full command lifecycle, flag parsing |
399+
| `runCommand()` in b2c-cli | Testing actual CLI commands |
400+
387401
## E2E Tests
388402

389403
E2E tests run against real infrastructure and are skipped without credentials:

packages/b2c-tooling-sdk/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,14 @@
210210
"@eslint/compat": "^1",
211211
"@oclif/core": "^4",
212212
"@oclif/prettier-config": "^0.2.1",
213+
"@oclif/test": "^4.1.14",
213214
"@salesforce/dev-config": "^4.3.2",
214215
"@tony.ganchev/eslint-plugin-header": "^3.1.11",
215216
"@types/archiver": "^7.0.0",
216217
"@types/chai": "^4.3.20",
217218
"@types/mocha": "^10.0.10",
218219
"@types/node": "^18.19.130",
220+
"@types/sinon": "^21.0.0",
219221
"@types/xml2js": "^0.4.14",
220222
"c8": "^10.1.3",
221223
"chai": "^4.5.0",
@@ -227,6 +229,7 @@
227229
"openapi-typescript": "^7.10.1",
228230
"prettier": "^3.6.2",
229231
"shx": "^0.3.3",
232+
"sinon": "^21.0.1",
230233
"tsx": "^4.20.6",
231234
"typescript": "^5",
232235
"typescript-eslint": "^8"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
import {expect} from 'chai';
7+
import {runCommand} from '@oclif/test';
8+
import path from 'node:path';
9+
import {fileURLToPath} from 'node:url';
10+
11+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
12+
const fixtureRoot = path.join(__dirname, '../fixtures/test-cli');
13+
14+
describe('BaseCommand integration', () => {
15+
it('runs test-base command without errors', async () => {
16+
const {error} = await runCommand(['test-base'], {root: fixtureRoot});
17+
expect(error).to.be.undefined;
18+
});
19+
20+
it('handles --extra-query flag', async () => {
21+
const {error, result} = await runCommand<{extraParams?: Record<string, unknown>}>(
22+
['test-base', '--extra-query', '{"debug":"true"}', '--json'],
23+
{root: fixtureRoot},
24+
);
25+
26+
expect(error).to.be.undefined;
27+
expect(result?.extraParams?.query).to.deep.equal({debug: 'true'});
28+
});
29+
30+
it('handles --extra-body flag', async () => {
31+
const {error, result} = await runCommand<{extraParams?: Record<string, unknown>}>(
32+
['test-base', '--extra-body', '{"_internal":true}', '--json'],
33+
{root: fixtureRoot},
34+
);
35+
36+
expect(error).to.be.undefined;
37+
expect(result?.extraParams?.body).to.deep.equal({_internal: true});
38+
});
39+
40+
it('handles both --extra-query and --extra-body flags', async () => {
41+
const {error, result} = await runCommand<{extraParams?: Record<string, unknown>}>(
42+
['test-base', '--extra-query', '{"debug":"true"}', '--extra-body', '{"_internal":true}', '--json'],
43+
{root: fixtureRoot},
44+
);
45+
46+
expect(error).to.be.undefined;
47+
expect(result?.extraParams?.query).to.deep.equal({debug: 'true'});
48+
expect(result?.extraParams?.body).to.deep.equal({_internal: true});
49+
});
50+
51+
it('returns undefined extraParams when no extra flags provided', async () => {
52+
const {error, result} = await runCommand<{extraParams?: Record<string, unknown>}>(['test-base', '--json'], {
53+
root: fixtureRoot,
54+
});
55+
56+
expect(error).to.be.undefined;
57+
expect(result?.extraParams).to.be.undefined;
58+
});
59+
});

0 commit comments

Comments
 (0)