@gunshi/docs 0.27.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,843 @@
1
+ # Plugin Testing
2
+
3
+ Testing is crucial for ensuring plugin reliability and maintainability. This guide covers practical testing approaches for Gunshi plugins.
4
+
5
+ <!-- eslint-disable markdown/no-missing-label-refs -->
6
+
7
+ > [!IMPORTANT]
8
+ > The code examples in this guide focus on demonstrating testing patterns and techniques. For clarity and brevity, some examples may not include extensive explanations that would normally be present in Gunshi's documentation. The emphasis is on showing practical testing approaches rather than following all documentation style guidelines.
9
+
10
+ <!-- eslint-enable markdown/no-missing-label-refs -->
11
+
12
+ <!-- eslint-disable markdown/no-missing-label-refs -->
13
+
14
+ > [!NOTE]
15
+ > This guide uses [Vitest](https://vitest.dev/) as the testing framework. The concepts can be adapted to other testing frameworks.
16
+
17
+ <!-- eslint-enable markdown/no-missing-label-refs -->
18
+
19
+ <!-- eslint-disable markdown/no-missing-label-refs -->
20
+
21
+ > [!NOTE]
22
+ > Some code examples include TypeScript file extensions (`.ts`) in `import`/`export` statements. If you use this pattern, enable `allowImportingTsExtensions` in your `tsconfig.json`.
23
+
24
+ <!-- eslint-enable markdown/no-missing-label-refs -->
25
+
26
+ ## Plugin Testing Fundamentals
27
+
28
+ Every Gunshi plugin requires thorough testing to ensure it integrates correctly with the CLI framework and behaves reliably across different scenarios. This section covers the essential concepts and basic testing approaches that form the foundation of plugin testing. You'll learn how to structure tests for plugins, use the testing utilities provided by Gunshi, and establish a solid testing foundation that scales with your plugin's complexity.
29
+
30
+ ### Basic Plugin Structure
31
+
32
+ The following example demonstrates a minimal plugin structure that serves as our testing subject:
33
+
34
+ ```ts [src/plugin.ts]
35
+ import { plugin } from 'gunshi/plugin'
36
+
37
+ export function myPlugin(options = {}) {
38
+ return plugin({
39
+ id: 'my-plugin',
40
+ name: 'My Plugin',
41
+ extension: (ctx, cmd) => ({
42
+ greet: (name: string) => `Hello, ${name}!`
43
+ })
44
+ })
45
+ }
46
+ ```
47
+
48
+ ### Writing Your First Test
49
+
50
+ The following test file demonstrates basic plugin testing, including initialization and extension factory verification:
51
+
52
+ ```ts [src/plugin.test.ts]
53
+ import { createCommandContext } from 'gunshi/plugin'
54
+ import { describe, expect, test, vi } from 'vitest'
55
+ import { myPlugin } from './plugin.ts'
56
+
57
+ describe('plugin initialization', () => {
58
+ test('create plugin with default options', () => {
59
+ const plugin = myPlugin()
60
+
61
+ expect(plugin.id).toBe('my-plugin')
62
+ expect(plugin.name).toBe('My Plugin')
63
+ expect(plugin.extension.factory).toBeDefined()
64
+ })
65
+
66
+ test('plugin extension factory creates correct methods', async () => {
67
+ const plugin = myPlugin()
68
+ const mockCommand = {
69
+ name: 'test',
70
+ description: 'Test command',
71
+ run: vi.fn()
72
+ }
73
+
74
+ const mockContext = await createCommandContext({
75
+ command: mockCommand
76
+ })
77
+
78
+ const extension = await plugin.extension.factory(mockContext, mockCommand)
79
+
80
+ expect(extension.greet).toBeDefined()
81
+ expect(typeof extension.greet).toBe('function')
82
+ expect(extension.greet('World')).toBe('Hello, World!')
83
+ })
84
+ })
85
+ ```
86
+
87
+ ### Moving Beyond Basic Tests
88
+
89
+ The example above demonstrates fundamental plugin testing. As your plugins grow more complex, you'll need to test additional aspects to ensure reliability and maintainability. The following sections cover comprehensive testing approaches for:
90
+
91
+ - Plugin configuration - Validating options and handling invalid inputs
92
+ - Extension behaviors - Testing complex extension methods and async operations
93
+ - Lifecycle management - Verifying setup functions and onExtension callbacks
94
+ - Decorator patterns - Testing command and renderer decorators
95
+ - Integration scenarios - Ensuring plugins work correctly with real CLI instances
96
+
97
+ Each section includes practical examples and testing techniques you can adapt to your plugins. To run your tests after implementing them, use:
98
+
99
+ ```sh
100
+ # Run all tests
101
+ vitest run
102
+ ```
103
+
104
+ Let's start by exploring how to test plugin configuration validation.
105
+
106
+ ## Testing Plugin Configuration
107
+
108
+ Plugins often accept configuration options that modify their behavior, validate input, or declare dependencies on other plugins. Robust configuration testing ensures your plugin handles both valid and invalid inputs gracefully, properly validates options at creation time, and correctly declares its dependencies. This section demonstrates comprehensive approaches to testing all aspects of plugin configuration, from basic validation to complex dependency scenarios.
109
+
110
+ ### Configuration Validation
111
+
112
+ Plugins should validate configuration during creation:
113
+
114
+ ```ts [src/plugin.ts]
115
+ import { plugin } from 'gunshi/plugin'
116
+
117
+ export function myValidatingPlugin(options: { locale: string; timeout?: number }) {
118
+ if (options.locale && !/^[a-z]{2}-[A-Z]{2}$/.test(options.locale)) {
119
+ throw new Error('Invalid locale format')
120
+ }
121
+ if (options.timeout !== undefined && options.timeout < 0) {
122
+ throw new Error('Timeout must be positive')
123
+ }
124
+
125
+ return plugin({
126
+ id: 'validating-plugin',
127
+ name: 'Validating Plugin',
128
+ extension: (ctx, cmd) => ({
129
+ validate: () => true,
130
+ config: options
131
+ })
132
+ })
133
+ }
134
+ ```
135
+
136
+ ### Testing Configuration Behavior
137
+
138
+ Test your configuration validation logic with these testing approaches:
139
+
140
+ ```ts [src/plugin.test.ts]
141
+ import { createCommandContext } from 'gunshi/plugin'
142
+ import { describe, expect, test, vi } from 'vitest'
143
+ import { myValidatingPlugin } from './plugin.ts'
144
+
145
+ describe('configuration validation', () => {
146
+ test('validates configuration at creation time', () => {
147
+ expect(() => myValidatingPlugin({ locale: 'invalid' })).toThrow('Invalid locale format')
148
+ expect(() => myValidatingPlugin({ locale: 'en-US', timeout: -1 })).toThrow(
149
+ 'Timeout must be positive'
150
+ )
151
+ })
152
+
153
+ test('plugin uses configuration in extension', async () => {
154
+ const plugin = myValidatingPlugin({ locale: 'ja-JP', timeout: 5000 })
155
+ const mockCommand = { name: 'test', description: 'Test', run: vi.fn() }
156
+
157
+ const ctx = await createCommandContext({ command: mockCommand })
158
+ const extension = await plugin.extension.factory(ctx, mockCommand)
159
+
160
+ expect(extension.config.locale).toBe('ja-JP')
161
+ expect(extension.config.timeout).toBe(5000)
162
+ })
163
+ })
164
+ ```
165
+
166
+ ### Dependency Declaration
167
+
168
+ Define a plugin with dependencies using the following structure:
169
+
170
+ ```ts [src/plugin.ts]
171
+ import { plugin } from 'gunshi/plugin'
172
+
173
+ export function myPluginWithDependencies() {
174
+ return plugin({
175
+ id: 'dependent-plugin',
176
+ name: 'Dependent Plugin',
177
+ dependencies: ['plugin-renderer', { id: 'plugin-i18n', optional: true }],
178
+ extension: (ctx, cmd) => ({
179
+ render: () => 'rendered'
180
+ })
181
+ })
182
+ }
183
+ ```
184
+
185
+ Test dependency declarations:
186
+
187
+ ```ts [src/plugin.test.ts]
188
+ import { describe, expect, test } from 'vitest'
189
+ import { myPluginWithDependencies } from './plugin.ts'
190
+
191
+ describe('dependency declaration', () => {
192
+ test('declares dependencies correctly', () => {
193
+ const plugin = myPluginWithDependencies()
194
+
195
+ expect(plugin.dependencies).toContain('plugin-renderer')
196
+ const optionalDep = plugin.dependencies?.find(dep => typeof dep === 'object' && dep.optional)
197
+ expect(optionalDep).toEqual({ id: 'plugin-i18n', optional: true })
198
+ })
199
+ })
200
+ ```
201
+
202
+ ## Testing Extensions
203
+
204
+ Extensions are the heart of Gunshi plugins, providing the actual functionality that commands can use. Testing extensions requires special attention to their interaction with the command context, handling of asynchronous operations, and proper state management. This section explores various patterns for testing extensions using the `createCommandContext` helper, covering both simple synchronous methods and complex asynchronous workflows that interact with external resources.
205
+
206
+ ### Testing with `createCommandContext`
207
+
208
+ The `createCommandContext` helper provides a complete context for testing extensions:
209
+
210
+ ```ts [src/plugin.test.ts]
211
+ import { createCommandContext } from 'gunshi/plugin'
212
+ import { expect, test } from 'vitest'
213
+ import { myPlugin } from './plugin.ts'
214
+
215
+ test('extension factory creates correct extension', async () => {
216
+ const plugin = myPlugin({ debug: true })
217
+
218
+ const command = { name: 'test', description: 'Test', run: vi.fn() }
219
+ const ctx = await createCommandContext({
220
+ command,
221
+ values: { verbose: true },
222
+ cliOptions: {
223
+ version: '1.0.0'
224
+ }
225
+ })
226
+
227
+ const extension = await plugin.extension.factory(ctx, command)
228
+
229
+ expect(extension.someMethod).toBeDefined()
230
+ const result = extension.someMethod()
231
+ expect(result).toBe('expected value')
232
+ })
233
+ ```
234
+
235
+ ### Testing Complex Extension Methods
236
+
237
+ The following example demonstrates how to test extension methods that interact with the command context:
238
+
239
+ ```ts [src/plugin.test.ts]
240
+ import { createCommandContext } from 'gunshi/plugin'
241
+ import { describe, expect, test, vi } from 'vitest'
242
+ import { myPlugin } from './plugin.ts'
243
+
244
+ describe('extension methods', () => {
245
+ test('showVersion displays version correctly', async () => {
246
+ const plugin = myPlugin()
247
+
248
+ const command = { name: 'app', description: 'App', run: vi.fn() }
249
+ const ctx = await createCommandContext({
250
+ command,
251
+ cliOptions: { version: '2.0.0', name: 'test-app' }
252
+ })
253
+
254
+ const extension = await plugin.extension.factory(ctx, command)
255
+ const result = extension.showVersion()
256
+
257
+ expect(result).toBe('2.0.0')
258
+ })
259
+
260
+ test('handles missing version', async () => {
261
+ const plugin = myPlugin()
262
+
263
+ const command = { name: 'app', description: 'App', run: vi.fn() }
264
+ const ctx = await createCommandContext({
265
+ command,
266
+ cliOptions: { version: undefined, name: 'test-app' }
267
+ })
268
+
269
+ const extension = await plugin.extension.factory(ctx, command)
270
+ const result = extension.showVersion()
271
+
272
+ expect(result).toBe('unknown')
273
+ })
274
+ })
275
+ ```
276
+
277
+ ### Testing Async Extensions
278
+
279
+ The following examples show how to test asynchronous extension factories that load configuration and handle errors:
280
+
281
+ ```ts [src/plugin.test.ts]
282
+ import { createCommandContext } from 'gunshi/plugin'
283
+ import { describe, expect, test, vi } from 'vitest'
284
+ import myPlugin from './plugin.ts'
285
+
286
+ describe('async extension', () => {
287
+ test('extension factory loads configuration', async () => {
288
+ const loadConfig = vi.fn().mockReturnValue({
289
+ apiUrl: 'https://api.example.com',
290
+ timeout: 5000
291
+ })
292
+ const plugin = myPlugin({ loadConfig })
293
+ const command = { name: 'test', description: 'Test', run: vi.fn() }
294
+ const ctx = await createCommandContext({ command })
295
+ const extension = await plugin.extension.factory(ctx, command)
296
+
297
+ expect(loadConfig).toHaveBeenCalled()
298
+ expect(extension.getConfig()).toEqual({
299
+ apiUrl: 'https://api.example.com',
300
+ timeout: 5000
301
+ })
302
+ })
303
+
304
+ test('handles initialization errors gracefully', async () => {
305
+ const warnMock = vi.spyOn(console, 'warn').mockImplementation(() => {})
306
+ const loadConfig = vi.fn().mockRejectedValue(new Error('Config not found'))
307
+ const plugin = myPlugin({ loadConfig })
308
+ const command = { name: 'test', description: 'Test', run: vi.fn() }
309
+ const ctx = await createCommandContext({ command })
310
+ const extension = await plugin.extension.factory(ctx, command)
311
+
312
+ expect(extension.getConfig()).toEqual({
313
+ apiUrl: 'http://localhost:3000',
314
+ timeout: 3000
315
+ })
316
+ expect(warnMock).toHaveBeenCalledWith('Failed to load config:', expect.any(Error))
317
+ })
318
+ })
319
+ ```
320
+
321
+ ## Testing Lifecycle
322
+
323
+ Gunshi plugins follow a specific lifecycle with distinct phases: initialization through the setup function and post-creation callbacks via onExtension. Understanding and testing these lifecycle hooks is essential for plugins that modify the CLI structure, add global options, or maintain shared state across commands.
324
+
325
+ This section covers strategies for testing each lifecycle phase and ensuring your plugin integrates seamlessly with the CLI initialization process.
326
+
327
+ ### Testing the `setup` Function
328
+
329
+ The `setup` function runs when the plugin is registered:
330
+
331
+ ```ts [src/plugin.ts]
332
+ import { plugin } from 'gunshi/plugin'
333
+
334
+ export interface DebuggablePluginOptions {
335
+ enable?: boolean
336
+ }
337
+
338
+ export function debuggablePlugin({ enable = true }: DebuggablePluginOptions) {
339
+ return plugin({
340
+ id: 'debuggable-plugin',
341
+ name: 'Debuggable Plugin',
342
+ setup: ctx => {
343
+ if (enable) {
344
+ ctx.addGlobalOption('debug', {
345
+ type: 'boolean',
346
+ description: 'Enable debug mode',
347
+ default: false
348
+ })
349
+ }
350
+ }
351
+ })
352
+ }
353
+ ```
354
+
355
+ Test `setup` behavior:
356
+
357
+ ```ts [src/plugin.test.ts]
358
+ import { createCommandContext } from 'gunshi/plugin'
359
+ import { describe, expect, test, vi } from 'vitest'
360
+ import { debuggablePlugin } from './plugin.ts'
361
+
362
+ // Mockup PluginContext
363
+ function mockPluginContext(): PluginContext {
364
+ return {
365
+ subCommands: new Map(),
366
+ globalOptions: new Map(),
367
+ addGlobalOption: vi.fn(),
368
+ addCommand: vi.fn(),
369
+ hasCommand: vi.fn(),
370
+ decorateCommand: vi.fn(),
371
+ decorateUsageRenderer: vi.fn(),
372
+ decorateHeaderRenderer: vi.fn(),
373
+ decorateValidationErrorsRenderer: vi.fn()
374
+ }
375
+ }
376
+
377
+ describe('debuggable plugin', () => {
378
+ test('enable debug option', async () => {
379
+ const mockPluginCtx = mockPluginContext()
380
+ const plugin = debuggablePlugin({ enable: true })
381
+
382
+ // Run the plugin setup
383
+ plugin(mockPluginCtx)
384
+
385
+ expect(mockPluginCtx.addGlobalOption).toHaveBeenCalledWith(
386
+ 'debug',
387
+ expect.objectContaining({ type: 'boolean', description: 'Enable debug mode', default: false })
388
+ )
389
+ })
390
+
391
+ test('disable debug option', () => {
392
+ const mockPluginCtx = mockPluginContext()
393
+ const plugin = debuggablePlugin({ enable: false })
394
+
395
+ plugin(mockPluginCtx)
396
+
397
+ expect(mockPluginCtx.addGlobalOption).not.toHaveBeenCalledWith(
398
+ 'debug',
399
+ expect.objectContaining({ type: 'boolean', description: 'Enable debug mode', default: false })
400
+ )
401
+ })
402
+ })
403
+ ```
404
+
405
+ ### Testing the `onExtension` Callback
406
+
407
+ The `onExtension` callback runs after extensions are created:
408
+
409
+ ```ts [src/plugin.ts]
410
+ import { plugin } from 'gunshi/plugin'
411
+
412
+ interface TrackerState {
413
+ initialized: boolean
414
+ initTime?: number | undefined
415
+ commandName?: string | undefined
416
+ }
417
+
418
+ export function trackerPlugin() {
419
+ const sharedState = {
420
+ initialized: false,
421
+ initTime: null as number | null,
422
+ commandName: null as string | null
423
+ }
424
+
425
+ return plugin({
426
+ id: 'tracker-plugin',
427
+ name: 'Tracker Plugin',
428
+
429
+ extension: (ctx, cmd) => ({
430
+ get isInitialized() {
431
+ return sharedState.initialized
432
+ },
433
+ getInitTime: () => sharedState.initTime,
434
+ getCommandName: () => sharedState.commandName
435
+ }),
436
+
437
+ onExtension: (ctx, cmd) => {
438
+ sharedState.initialized = true
439
+ sharedState.initTime = Date.now()
440
+ sharedState.commandName = cmd.name
441
+ }
442
+ })
443
+ }
444
+ ```
445
+
446
+ Test `onExtension` behavior:
447
+
448
+ ```ts [src/plugin.test.ts]
449
+ import { createCommandContext } from 'gunshi/plugin'
450
+ import { describe, expect, test, vi } from 'vitest'
451
+ import { trackerPlugin } from './init-tracker.ts'
452
+
453
+ describe('onExtension callback', () => {
454
+ test('extension not initialized before onExtension', async () => {
455
+ const plugin = trackerPlugin()
456
+ const command = { name: 'test', description: 'Test', run: vi.fn() }
457
+ const ctx = await createCommandContext({ command })
458
+ const extension = await plugin.extension.factory(ctx, command)
459
+
460
+ expect(extension.isInitialized).toBe(false)
461
+ expect(extension.getInitTime()).toBeUndefined()
462
+ })
463
+
464
+ test('onExtension initializes extension', async () => {
465
+ const plugin = trackerPlugin()
466
+ const command = { name: 'deploy', description: 'Deploy', run: vi.fn() }
467
+ const ctx = await createCommandContext({ command })
468
+ const extension = await plugin.extension.factory(ctx, command)
469
+ await plugin.extension.onFactory?.(ctx, command)
470
+
471
+ expect(extension.isInitialized).toBe(true)
472
+ expect(extension.getInitTime()).toBeGreaterThan(0)
473
+ expect(extension.getCommandName()).toBe('deploy')
474
+ })
475
+ })
476
+ ```
477
+
478
+ ## Testing Decorators
479
+
480
+ Decorators allow plugins to wrap and enhance existing command runners and renderers, adding functionality like logging, error handling, or output formatting. Testing decorators requires verifying both their enhancement behavior and their ability to properly delegate to the underlying functions. This section demonstrates techniques for testing command and renderer decorators, ensuring they intercept correctly when needed and pass through transparently when appropriate.
481
+
482
+ ### Command Decorator Testing
483
+
484
+ Test decorators that wrap command runners:
485
+
486
+ ```ts [src/decorator.test.ts]
487
+ import { createCommandContext } from 'gunshi/plugin'
488
+ import { describe, expect, test, vi } from 'vitest'
489
+ import { commandDecorator } from './decorators.ts'
490
+
491
+ describe('command decorator', () => {
492
+ test('intercepts help option', async () => {
493
+ const usage = 'Usage: test [options]'
494
+
495
+ const ctx = await createCommandContext({
496
+ command: { name: 'test', description: 'Test', run: vi.fn() },
497
+ values: { help: true },
498
+ cliOptions: {
499
+ renderUsage: async () => usage
500
+ }
501
+ })
502
+
503
+ const baseRunner = vi.fn(() => 'command executed')
504
+ const decoratedRunner = commandDecorator(baseRunner)
505
+ const result = await decoratedRunner(ctx)
506
+
507
+ expect(result).toBe(usage)
508
+ expect(baseRunner).not.toHaveBeenCalled()
509
+ })
510
+
511
+ test('passes through normally', async () => {
512
+ const ctx = await createCommandContext({
513
+ command: { name: 'test', description: 'Test', run: vi.fn() }
514
+ })
515
+
516
+ const baseRunner = vi.fn(() => 'command executed')
517
+ const decoratedRunner = commandDecorator(baseRunner)
518
+ const result = await decoratedRunner(ctx)
519
+
520
+ expect(result).toBe('command executed')
521
+ expect(baseRunner).toHaveBeenCalledWith(ctx)
522
+ })
523
+ })
524
+ ```
525
+
526
+ ### Testing Renderer Decorators
527
+
528
+ The following example shows how to test extensions that interact with renderer decorators:
529
+
530
+ ```ts [src/renderer.test.ts]
531
+ import { createCommandContext } from 'gunshi/plugin'
532
+ import { expect, test, vi } from 'vitest'
533
+ import myPlugin from './plugin.ts'
534
+ import { headerRendererDecorator } from './decorators.ts'
535
+
536
+ import type { PluginId } from './constants.ts'
537
+ import type { MyPluginExtension } from './types.ts'
538
+
539
+ describe('renderer decorators: decorateHeaderRenderer', async () => {
540
+ test('render header with renderHeader option', async () => {
541
+ const plugin = myPlugin()
542
+ const header = 'Test Application v1.0.0'
543
+ const ctx = await createCommandContext<{ extensions: Record<PluginId, MyPluginExtension> }>({
544
+ command: { name: 'app', description: 'App', run: vi.fn() },
545
+ extensions: {
546
+ [plugin.id]: plugin.extension
547
+ },
548
+ cliOptions: {
549
+ renderHeader: async () => header
550
+ }
551
+ })
552
+
553
+ const baseRunner = vi.fn<Parameters<typeof headerRendererDecorator>[0]>(
554
+ async () => 'header rendered'
555
+ )
556
+ const rendered = await headerRendererDecorator(baseRunner, ctx)
557
+ expect(rendered).toBe(header)
558
+ expect(baseRunner).not.toHaveBeenCalled()
559
+ })
560
+
561
+ test('not render Header without renderHeader option', async () => {
562
+ const plugin = myPlugin()
563
+ const ctx = await createCommandContext<{ extensions: Record<PluginId, MyPluginExtension> }>({
564
+ command: { name: 'app', description: 'App', run: vi.fn() },
565
+ extensions: {
566
+ [plugin.id]: plugin.extension
567
+ }
568
+ })
569
+
570
+ const baseRunner = vi.fn<Parameters<typeof headerRendererDecorator>[0]>(
571
+ async () => 'header rendered'
572
+ )
573
+ const rendered = await headerRendererDecorator(baseRunner, ctx)
574
+ expect(rendered).toBe('header rendered')
575
+ })
576
+ })
577
+ ```
578
+
579
+ ## Integration Testing
580
+
581
+ While unit tests verify individual plugin components, integration tests ensure your plugin works correctly within a complete CLI environment with real command instances and potentially multiple interacting plugins. These tests validate the entire plugin lifecycle from registration through execution, including dependency resolution and inter-plugin communication. This section provides patterns for comprehensive integration testing that catches issues unit tests might miss.
582
+
583
+ ### Testing Complete Plugin Flow
584
+
585
+ Integration tests verify the entire plugin lifecycle. The following example shows a logging plugin that we'll use for integration testing:
586
+
587
+ ```ts [src/plugin.ts]
588
+ import { plugin } from 'gunshi/plugin'
589
+
590
+ export interface LoggingExtension {
591
+ log: (message: string) => void
592
+ }
593
+
594
+ export function loggingPlugin() {
595
+ return plugin({
596
+ id: 'logging-plugin',
597
+ name: 'Logging Plugin',
598
+
599
+ extension: (ctx, cmd) => ({
600
+ log: (message: string) => {
601
+ if (ctx.values.verbose) {
602
+ console.log(`[${cmd.name}] ${message}`)
603
+ }
604
+ }
605
+ }),
606
+
607
+ setup(ctx) {
608
+ ctx.addGlobalOption('verbose', {
609
+ type: 'boolean',
610
+ short: 'V',
611
+ description: 'Enable verbose logging'
612
+ })
613
+ }
614
+ })
615
+ }
616
+ ```
617
+
618
+ Test with real CLI instance:
619
+
620
+ ```ts [src/plugin.test.ts]
621
+ import { describe, expect, test, vi } from 'vitest'
622
+ import { cli, define } from 'gunshi'
623
+ import { loggingPlugin } from './plugin.ts'
624
+
625
+ describe('plugin integration', () => {
626
+ test('plugin adds global options to CLI', async () => {
627
+ const plugin = loggingPlugin()
628
+ const mockRun = vi.fn(() => 'success')
629
+ const command = define({
630
+ name: 'test-command',
631
+ description: 'Test command',
632
+ run: mockRun
633
+ })
634
+ await cli(['--verbose'], command, {
635
+ plugins: [plugin]
636
+ })
637
+
638
+ expect(mockRun).toHaveBeenCalledWith(
639
+ expect.objectContaining({
640
+ values: {
641
+ verbose: true
642
+ }
643
+ })
644
+ )
645
+ })
646
+ })
647
+ ```
648
+
649
+ ### Testing Multiple Plugins
650
+
651
+ Test plugin interactions with dependencies:
652
+
653
+ ```ts [src/plugin.ts]
654
+ import { plugin } from 'gunshi/plugin'
655
+
656
+ export interface NotificationExtension {
657
+ notify: (message: string) => void
658
+ }
659
+
660
+ const notificationDependencies = [{ id: 'logging-plugin', optional: true }] as const
661
+
662
+ export function notificationPlugin() {
663
+ return plugin<
664
+ Record<'logging-plugin', LoggingExtension>,
665
+ 'notification-plugin',
666
+ typeof notificationDependencies
667
+ >({
668
+ id: 'notification-plugin',
669
+ name: 'Notification Plugin',
670
+ dependencies: notificationDependencies,
671
+ extension: (ctx, cmd) => {
672
+ const logger = ctx.extensions['logging-plugin']?.log
673
+ return {
674
+ notify(message: string) {
675
+ if (logger) {
676
+ logger(`[DEBUG] ${message}`)
677
+ } else {
678
+ console.log(message)
679
+ }
680
+ }
681
+ }
682
+ }
683
+ })
684
+ }
685
+ ```
686
+
687
+ Test plugin discovery and fallback:
688
+
689
+ ```ts [src/plugin.test.ts]
690
+ import { createCommandContext } from 'gunshi/plugin'
691
+ import { describe, expect, test, vi } from 'vitest'
692
+ import { loggingPlugin, notificationPlugin } from './plugin.ts'
693
+
694
+ import type { LoggingExtension, NotificationExtension } from './plugin.ts'
695
+
696
+ describe('plugin interactions', () => {
697
+ test('uses logger when available', async () => {
698
+ const command = { name: 'test', description: 'Test', run: vi.fn() }
699
+
700
+ const logging = loggingPlugin()
701
+ const notification = notificationPlugin()
702
+
703
+ const logSpy = vi.fn()
704
+ vi.spyOn(logging.extension, 'factory').mockImplementation(async () => {
705
+ return { log: logSpy }
706
+ })
707
+ const ctx = await createCommandContext<{
708
+ extensions: Record<'logging-plugin', LoggingExtension> &
709
+ Record<'notification-plugin', NotificationExtension>
710
+ }>({
711
+ command,
712
+ extensions: {
713
+ [logging.id]: logging.extension,
714
+ [notification.id]: notification.extension
715
+ }
716
+ })
717
+
718
+ ctx.extensions['notification-plugin'].notify('Hello')
719
+ expect(logSpy).toHaveBeenCalledWith('[DEBUG] Hello')
720
+ })
721
+
722
+ test('falls back when logger missing', async () => {
723
+ const command = { name: 'test', description: 'Test', run: vi.fn() }
724
+ const notification = notificationPlugin()
725
+
726
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
727
+ const ctx = await createCommandContext<{
728
+ extensions: Record<'notification-plugin', NotificationExtension>
729
+ }>({
730
+ command,
731
+ extensions: {
732
+ [notification.id]: notification.extension
733
+ }
734
+ })
735
+
736
+ ctx.extensions['notification-plugin'].notify('Hello')
737
+ expect(logSpy).toHaveBeenCalledWith('Hello')
738
+ })
739
+ })
740
+ ```
741
+
742
+ ## Testing Techniques Reference
743
+
744
+ This reference section consolidates the key testing techniques and principles covered throughout this guide into a quick-access format. Use these techniques as templates for your own plugin tests, adapting them to your specific requirements while maintaining the core testing principles that ensure plugin reliability and maintainability.
745
+
746
+ ### Quick Reference
747
+
748
+ The following code snippets provide quick examples of common testing approaches:
749
+
750
+ ```ts
751
+ // Structure validation
752
+ expect(plugin.id).toBe('plugin-id')
753
+ expect(plugin.dependencies).toContain('dependency-id')
754
+
755
+ // Configuration validation
756
+ expect(() => plugin({ invalid: true })).toThrow()
757
+
758
+ // Extension testing with createCommandContext
759
+ const ctx = await createCommandContext({ command, values, cliOptions })
760
+ const extension = await plugin.extension.factory(ctx, command)
761
+
762
+ // Async extension testing
763
+ const loadConfig = vi.fn().mockReturnValue(config)
764
+ const plugin = myPlugin({ loadConfig })
765
+
766
+ // Lifecycle testing
767
+ plugin(mockPluginContext)
768
+ plugin.extension.onFactory?.(ctx, command)
769
+
770
+ // Command decorator testing
771
+ const commandDecorated = commandDecorator(baseRunner)
772
+ const result = await commandDecorated(ctx)
773
+
774
+ // Renderer decorators testing
775
+ const rendered = await rendererDecorator(baseRunner, ctx)
776
+
777
+ // Integration testing
778
+ await cli(['--option'], command, { plugins: [plugin] })
779
+
780
+ // Multiple plugins
781
+ const ctx = await createCommandContext({
782
+ extensions: {
783
+ plugin1: plugin1.extension,
784
+ plugin2: plugin2.extension
785
+ }
786
+ })
787
+ ```
788
+
789
+ ### Key Testing Principles
790
+
791
+ 1. Test behavior, not implementation: Focus on what the plugin does, not how
792
+ 2. Use createCommandContext: Provides complete type-safe context for testing
793
+ 3. Test edge cases: Invalid config, missing dependencies, async failures
794
+ 4. Isolate tests: Each test should be independent and deterministic
795
+ 5. Mock external dependencies: File system, network calls, timers
796
+ 6. Test lifecycle hooks: setup, onExtension, decorators
797
+ 7. Verify integration: Test plugins working together in real CLI
798
+
799
+ ### Common Testing Scenarios
800
+
801
+ - Plugin initialization with various configurations
802
+ - Extension method behavior with different context states
803
+ - Async operations and error handling
804
+ - Dependency resolution and fallback behavior
805
+ - Decorator application and pass-through
806
+ - Global option and command registration
807
+ - Multi-plugin interactions and discovery
808
+
809
+ ### Type-Safe Testing
810
+
811
+ The following example demonstrates creating a fully type-safe plugin with compile-time dependency checking:
812
+
813
+ ```ts
814
+ import { plugin } from 'gunshi/plugin'
815
+ import type { LoggerExtension } from './plugins/logger.ts'
816
+ import type { MyExtension } from './types.ts'
817
+
818
+ const typedPlugin = plugin<{ logger: LoggerExtension }, 'my-plugin', ['logger'], MyExtension>({
819
+ id: 'my-plugin',
820
+ dependencies: ['logger'],
821
+ extension: (ctx, cmd) => ({
822
+ doSomething: () => {
823
+ ctx.extensions.logger.debug('Working')
824
+ return 'done'
825
+ }
826
+ })
827
+ })
828
+ ```
829
+
830
+ This ensures compile-time type safety for dependencies and extensions.
831
+
832
+ <!-- eslint-disable markdown/no-missing-label-refs -->
833
+
834
+ > [!TIP]
835
+ > The example fully code is [here](https://github.com/kazupon/gunshi/tree/main/playground/plugins/testing).
836
+
837
+ <!-- eslint-enable markdown/no-missing-label-refs -->
838
+
839
+ ## Next Steps
840
+
841
+ Now that you've mastered testing strategies for Gunshi plugins—from unit testing individual components to integration testing with real CLI contexts—you're ready to apply professional development practices.
842
+
843
+ The next chapter, [Plugin Development Guidelines](./guidelines.md), provides comprehensive guidelines for building production-ready plugins, including performance optimization, error handling strategies, and documentation standards that will help you create robust and maintainable plugins for the Gunshi ecosystem.