Nano Banana Pro
Agent skill for nano-banana-pro
このドキュメントは、Robert C. Martin(Uncle Bob)の**4層オニオンアーキテクチャ**とVladimir Khorikivの**単体テストの原則、実践、パターン**に従って構築されたTypeScript製testcliアプリケーションのアーキテクチャとテストガイドラインを説明しています。**関数モジュール**、**Command Query Separation (CQS)**、**Dependency Inversion Principle (DIP)** に重点を置いた、堅牢でテスト可能なEthereumトランザクション構築・署名・送信CLIアプリの作成を目標として
Sign in to like and favorite skills
このドキュメントは、Robert C. Martin(Uncle Bob)の4層オニオンアーキテクチャとVladimir Khorikivの単体テストの原則、実践、パターンに従って構築されたTypeScript製testcliアプリケーションのアーキテクチャとテストガイドラインを説明しています。関数モジュール、Command Query Separation (CQS)、Dependency Inversion Principle (DIP) に重点を置いた、堅牢でテスト可能なEthereumトランザクション構築・署名・送信CLIアプリの作成を目標としています。
testcli)で、入力(例:--to, --amount, --privateKey)を受け取り、Ethereumトランザクションを構築・署名し、(最終的に)テストネットに送信する。このプロジェクトはUncle BobのClean Architectureに従い、
enterprise-business-rules、application-business-rules、interface-adapter、frameworksの4層で構成されています。各層には特定の役割があり、依存関係は内側に向かって流れます(外側の層が内側の層に依存)。
interface-adapaters以上では、Nodeには依存しないが、TypeScript(JavaScript)の提供するオブジェクト等には依存してよい。
Transactionエンティティはアドレス、金額、ガス制限を検証する。buildTransaction(クエリ)やsignTransaction(コマンド)などのユースケース。buildTransaction): データを返し、副作用なし。signTransaction): 副作用を起こし、最小限のデータを返す。application-business-rules/interfacesの依存関係(例:Provider、Wallet)をモック。adaptExternalTransactionReceiptToDomainは外部ライブラリのTransactionReceiptを内部のTransactionReceiptエンティティに変換。application-business-rules/interfacesのインターフェースを実装し、CLIエントリーポイントをホスト。EthersInfuraProviderはProviderを実装;EthersWalletはWalletを実装;CLIは入力を解析。src/ ├── enterprise-business-rules/ │ ├── entities/ │ ├── interfaces/ │ ├── errors/ │ └── __tests__/ ├── application-business-rules/ │ ├── entities/ │ ├── usecases/ │ ├── interfaces/ │ ├── errors/ │ └── __tests__/ ├── interface-adapters/ │ ├── adapters/ │ ├── repositories/ │ ├── errors/ │ └── __tests__/ └── frameworks/ ├── commanders/ ├── ethers/ └── node/
テストはKhorikivの『単体テストの原則、実践、パターン』に従い、堅牢で保守性の高いテストを確保します。
スコープ:
enterprise-business-rules(エンティティ)、application-business-rules(ユースケース)、interface-adapters(アダプター)。
原則:
vi.mockを使用してapplication-business-rulesインターフェース(例:Provider、Wallet)をモック。例(
src/application-business-rules/__tests__/build-transaction.test.tsより):
import { vi, describe, it, expect } from 'vitest' import { buildTransaction } from '../usecases/build-transaction' import { Provider } from '../interfaces/provider' describe('buildTransaction', () => { const mockProvider: Provider = { getBalance: vi.fn().mockResolvedValue(BigInt('1000000000000000000')), getFeeData: vi .fn() .mockResolvedValue({ maxFeePerGas: BigInt('1000'), maxPriorityFeePerGas: BigInt('100') }), getTransactionCount: vi.fn().mockResolvedValue(0), broadcastTransaction: vi.fn().mockResolvedValue({ hash: '0x123' }), getTransactionReceipt: vi.fn().mockResolvedValue(null), } it('throws for invalid address', async () => { await expect(buildTransaction('invalid', '1.5', mockProvider, '0x123')).rejects.toThrow( 'Invalid address' ) }) })
カバレッジ目標:
enterprise-business-rules、application-business-rules、interface-adaptersで70%(npm run coverageで実行)。
スコープ:
enterprise-business-rules、application-business-rules、interface-adaptersの統合を検証(frameworksを除く)。
原則:
frameworksのEthersInfuraProviderやEthersWalletではなく、application-business-rules/interfacesのProviderとWalletをモック。frameworks層(ethers.js、Infura)は信頼できないものとして扱い、常にモック。例(
tests/integration/transaction.integration.test.tsより):
import { vi, describe, it, expect } from 'vitest' import { buildTransaction } from '../../src/application-business-rules/usecases/build-transaction' import { signTransaction } from '../../src/application-business-rules/usecases/sign-transaction' import { Provider } from '../../src/application-business-rules/interfaces/provider' import { Wallet } from '../../src/application-business-rules/interfaces/wallet' describe('Transaction Integration', () => { const mockProvider: Provider = { getBalance: vi.fn().mockResolvedValue(BigInt('1000000000000000000')), getFeeData: vi .fn() .mockResolvedValue({ maxFeePerGas: BigInt('1000'), maxPriorityFeePerGas: BigInt('100') }), getTransactionCount: vi.fn().mockResolvedValue(0), broadcastTransaction: vi.fn().mockResolvedValue({ hash: '0x123' }), getTransactionReceipt: vi.fn().mockResolvedValue(null), } const mockWallet: Wallet = { address: '0x1234567890123456789012345678901234567890', signTransaction: vi.fn().mockResolvedValue('0xSignedTx'), } it('builds and signs transaction', async () => { const tx = await buildTransaction( '0x1234567890123456789012345678901234567890', '1.5', mockProvider, mockWallet.address ) const signedTx = await signTransaction(tx, mockWallet) expect(signedTx).toBe('0xSignedTx') }) })
注記:
frameworks層は直接テストせず、そのインターフェースをモックして外部動作をシミュレート。
build-transaction.ts、sign-transaction.ts)は、シンプルさとテスタビリティのため、クラスではなく関数モジュールとして実装。frameworks層のCLIエントリーポイント:
src/frameworks/node/index.tsに配置し、引数解析にcommander.jsを使用。src/frameworks/commanders/に分離。process.argv、環境変数)を処理し依存関係を配線するため、frameworks層の役割に適合。ProviderとWalletインターフェース(application-business-rules/interfaces内)がethers.jsとInfuraを抽象化。EthersInfuraProvider、EthersWallet)はframeworksにあり、DIPを通じて注入。buildTransaction)はデータを返す;コマンド(例:signTransaction)は副作用を起こす。frameworksの最小限テスト:
frameworks層(例:EthersInfuraProvider、EthersWallet)は管理されていない依存関係(ethers.js、Infura)の薄いラッパー。frameworksの単体テストなし;統合テストでモックされたインターフェース経由でカバー。// enterprise-business-rules// application-business-rules// interface-adapters// frameworks// npm packages@/エイリアスを使用してsrcディレクトリを参照する(例:@/enterprise-business-rules/entities/transaction)。相対パス(../../../src/など)は使用しない。enterprise-business-rules、ユースケースとインターフェースはapplication-business-rules、アダプターはinterface-adapters、外部実装/CLIはframeworksに配置。export async function buildTransaction(...))として実装。frameworks実装ではなく、application-business-rulesインターフェース(例:Provider、Wallet)をモック。ProviderとWalletをモックし、enterpriseとapplication層の統合をテスト。getGasPriceが呼ばれたかをチェックしない)。enterprise-business-rules層: 自分の層のみからインポート可能application-business-rules層: 自分の層とenterprise-business-rulesからインポート可能interface-adapters層: 自分の層、application-business-rules、enterprise-business-rulesからインポート可能frameworks層: 任意の層からインポート可能(制限なし)frameworks層のimportルール: エントリーポイント(CLI)以外のframeworks層実装では、business rules 2層(enterprise-business-rules、application-business-rules)から型定義をインポートする場合は極力import typeを使用する。frameworksテストを最小化: frameworksを信頼できないものとして扱い、テストではそのインターフェースをモック。このセクションでは、今回のプロジェクトで実践したテスト構造化の具体的な方針を示します。
各テストファイルは以下の構造に従って組織化する:
describe('UseCase/Entity', () => { describe('successful scenarios', () => {}) // 正常系のテスト describe('business rules validation', () => {}) // ビジネスルールのテスト describe('edge cases', () => {}) // 境界値・特殊ケース describe('error handling', () => {}) // エラーハンドリング describe('delegation/transformation', () => {}) // 委譲・変換処理(必要に応じて) })
Managed Dependencies(実物使用):
enterprise-business-rules層のエンティティ(例:Transaction)interface-adapters層のアダプター(例:dataToTransaction, transactionToData)application-business-rules層のエンティティ(例:TransactionReceipt)Unmanaged Dependencies(モック使用):
frameworks層の実装(例:EthersInfuraProvider, EthersWallet, NodeSleeper)getFees, getNonce)describe('getFees', () => { describe('successful fee retrieval', () => { it('returns fee data with both values', async () => { // モックの設定 // 実行 // 戻り値の検証 // モック呼び出しの検証(回数・引数) }) }) describe('edge cases', () => { it('handles null values', async () => {}) it('handles very large values', async () => {}) }) describe('error handling', () => { it('propagates provider errors', async () => {}) }) describe('delegation behavior', () => { it('delegates directly without modification', async () => { // 同じ参照が返されることを確認(toBe使用) }) }) })
sendSignedTransaction)describe('sendSignedTransaction', () => { describe('transformation behavior', () => { it('transforms provider response to extract hash', async () => { // オブジェクト → 文字列への変換を検証 expect(typeof result).toBe('string') expect(result).toBe('0xtransformed_hash') }) }) })
buildTransaction, signTransaction)describe('buildTransaction', () => { describe('successful transaction building', () => { it('builds transaction with correct parameters', async () => { // 実際のadapterとentityを使用 const transactionAdapter = dataToTransaction(mockAddressValidator) const result = await buildTransaction(...) // 結果のプロパティを検証 expect(result.chainId).toBe(11155111) // unmanagedな依存関係のモック検証 expect(mockAddressValidator.validate).toHaveBeenCalledTimes(1) }) }) })
confirmTransaction)describe('confirmTransaction', () => { describe('timeout behavior', () => { it('throws error after 20 failed attempts', async () => { // 複数回の呼び出しをモック // リトライロジックの検証 expect(mockProvider.getTransactionReceipt).toHaveBeenCalledTimes(40) expect(mockSleeper.sleep).toHaveBeenCalledTimes(40) }) }) })
Khorikivの「モックしたら相互作用を検証する」原則を適用:
// 必須の検証項目 expect(mockFunction).toHaveBeenCalledWith(expectedArgs) // 引数の検証 expect(mockFunction).toHaveBeenCalledTimes(expectedCount) // 呼び出し回数の検証 // エラーケースでも必ず検証 await expect(useCase()).rejects.toThrow('Expected Error') expect(mockFunction).toHaveBeenCalledTimes(1) // エラー時も呼び出し回数を確認
固定値の検証:
バリデーション:
境界値:
describe('error handling', () => { it('propagates network errors', async () => {}) // ネットワーク系 it('propagates validation errors', async () => {}) // バリデーション系 it('propagates business rule violations', async () => {}) // ビジネスルール系 it('propagates external service errors', async () => {}) // 外部サービス系 })
returns nonce for valid address)throws error for invalid address)always uses Sepolia chain ID)handles very large values)getFees, getNonce)sendSignedTransaction)checkBalance)buildTransaction)signTransaction, confirmTransaction)各レベルで前のレベルのパターンを応用し、段階的に複雑さを増していく。
beforeEachで初期化describeでグループ化この構造化により、テストが仕様書として機能し、リファクタリング時の安全網となり、新機能追加時の指針となる。
複雑なワークフローをテスト可能にするため、このプロジェクトではOrchestratorパターンを採用しています。これは
frameworks層のCLIエントリーポイントに含まれていたビジネスロジックをテスト可能な形に抽出する手法です。
frameworks/node/index.ts)のビジネスロジックをテスト可能なユニットに抽出配置:
src/application-business-rules/orchestrators/
構成要素:
frameworks/factories/dependency-factory.tsで実際のframeworks実装を生成// SendTransactionOrchestrator例 export interface SendTransactionDependencies { provider: Provider // unmanaged dependency (mocked) wallet: Wallet // unmanaged dependency (mocked) sleeper: Sleeper // unmanaged dependency (mocked) addressValidator: AddressValidator // unmanaged dependency (mocked) } export interface SendTransactionParams { to: string amount: bigint } export async function orchestrateSendTransaction( dependencies: SendTransactionDependencies, params: SendTransactionParams, logger: Logger ): Promise<TransactionReceipt>
OrchestratorテストはKhorikivの統合テストに分類されます:
application-business-rules層内のユースケース統合application-business-rules/interfacesの抽象化(Provider, Wallet, Sleeper, AddressValidator)describe('orchestrateSendTransaction', () => { describe('successful workflow', () => { it('executes complete send workflow', async () => {}) it('logs each step appropriately', async () => {}) }) describe('error handling at each step', () => { it('fails at balance check step', async () => {}) it('fails at nonce retrieval step', async () => {}) it('fails at fee retrieval step', async () => {}) it('fails at transaction building step', async () => {}) it('fails at signing step', async () => {}) it('fails at broadcast step', async () => {}) it('fails at confirmation step', async () => {}) }) describe('workflow validation', () => { it('calls steps in correct order', async () => {}) it('uses managed dependencies correctly', async () => {}) }) describe('business rules enforcement', () => { it('enforces Sepolia chain ID', async () => {}) it('enforces EIP1559 transaction type', async () => {}) }) })
各ワークフロー段階での失敗を個別にテストし、エラー伝播とステップ中断を検証:
it('fails at balance check step', async () => { vi.mocked(mockProvider.getBalance).mockResolvedValue(BigInt('500000000000000000')) // Insufficient await expect(orchestrateSendTransaction(dependencies, params, mockLogger)).rejects.toThrow( 'Insufficient funds' ) // Should not proceed to further steps expect(mockProvider.getTransactionCount).not.toHaveBeenCalled() expect(mockProvider.getFeeData).not.toHaveBeenCalled() })
実行順序の正確性を保証するテスト:
it('calls steps in correct order', async () => { const callOrder: string[] = [] vi.mocked(mockProvider.getBalance).mockImplementation(async () => { callOrder.push('checkBalance') return BigInt('2000000000000000000') }) // 他のステップも同様に実装... await orchestrateSendTransaction(dependencies, params, mockLogger) expect(callOrder).toEqual([ 'checkBalance', 'getNonce', 'getFees', 'signTransaction', 'sendSignedTransaction', 'confirmTransaction', ]) })
it('uses managed dependencies correctly', async () => { await orchestrateSendTransaction(dependencies, params, mockLogger) // unmanaged dependencyのモック検証 expect(mockAddressValidator.validate).toHaveBeenCalledWith(params.to) expect(mockProvider.getBalance).toHaveBeenCalledWith(mockWallet.address) // managed dependencyは実物使用(Transaction, adaptersなど) // 検証は最終的な出力やビジネスルールで行う })
Orchestrator導入後の
frameworks/node/index.ts:
async function send(options: unknown): Promise<void> { // 1. バリデーション(CLI特有の責任) if (!isSendOptions(options)) { error('Invalid options for send command') return exit(1) } try { // 2. 依存関係の生成(Factory経由) const dependencies = createSendDependencies({ privateKey: options.privateKey, infuraUrl: options.infuraUrl, }) // 3. パラメータ変換(CLI特有の責任) const params = { to: options.to, amount: parseEtherInWei(options.amount), } // 4. ワークフロー実行(Orchestratorに委譲) await orchestrateSendTransaction(dependencies, params, { log }) log('completed successfully') } catch (error: unknown) { return handleError('Send Error', error) } }
CLIの責任:
Orchestratorの責任:
この分離により、CLIは薄いラッパーとなり、ビジネスロジックは完全にテスト可能なOrchestrator層で処理されます。