nullables
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseNullables and A-Frame Architecture
Nullables模式与A-Frame架构
Overview
概述
This skill guides implementation of James Shore's Nullables pattern and A-Frame architecture. This approach enables fast, reliable testing without mocks through:
- A-Frame Architecture: Separation of Logic, Infrastructure, and Application layers
- Nullables Pattern: Production code with an "off switch" for testing
- Static Factory Methods: for production,
.create()for tests.createNull() - State-Based Testing: Narrow, sociable tests that verify outputs instead of interactions
本技能指导如何实现James Shore的Nullables模式与A-Frame架构。该方法通过以下方式实现快速、可靠的无Mock测试:
- A-Frame Architecture:分离逻辑层、基础设施层与应用层
- Nullables Pattern:带有测试用“关闭开关”的生产代码
- Static Factory Methods:用于生产环境,
.create()用于测试环境.createNull() - State-Based Testing:窄范围、社交型测试,验证输出而非交互过程
When to Use This Pattern
适用场景
Apply this pattern when:
- If the code uses a dependency injection framework (eg effect-ts) then use it to perform injections; also inform me where this happens as we might need to think about where the boundary lies between a 3rd party injection approach and the use of Nullables (/
.create..createNull - Writing new code
- Refactoring existing code to eliminate mocks and improve test reliability
- Writing tests for code that uses nullable patterns such as classes that use static factory method .
.create
在以下场景中应用此模式:
- 如果代码使用依赖注入框架(如effect-ts),则使用该框架进行注入;同时请告知注入发生的位置,因为我们可能需要考虑第三方注入方式与Nullables(/
.create)的边界。.createNull - 编写新代码时
- 重构现有代码以消除Mock并提升测试可靠性时
- 为使用可空模式的代码(如使用静态工厂方法的类)编写测试时
.create
Core Principles
核心原则
1. Three-Layer Architecture
1. 三层架构
Organize code into distinct layers:
- Logic: Pure business logic and value objects (no external dependencies)
- Infrastructure: Code interfacing with external systems (network, filesystem, databases)
- Application: High-level orchestration coordinating logic and infrastructure
- application code is effectively infrastructure code because it contains instances of instrastructure that talk to the outside world
将代码组织为不同的层级:
- Logic:纯业务逻辑与值对象(无外部依赖)
- Infrastructure:与外部系统交互的代码(网络、文件系统、数据库)
- Application:协调逻辑层与基础设施层的高层编排代码
- 应用层代码本质上属于基础设施层,因为它包含与外部世界交互的基础设施实例
2. Dual Factory Methods
2. 双工厂方法
Every application and infrastructure class implements:
javascript
class ServiceClass {
static create(config) {
// Production instance - calls .create() on dependencies
const dependency = Dependency.create();
return new ServiceClass(dependency);
}
static createNull(config = {}) {
// Test instance - calls .createNull() on dependencies
const dependency = Dependency.createNull();
return new ServiceClass(dependency);
}
constructor(dependency) {
// Constructor receives instantiated dependencies
}
}每个应用层与基础设施层类都需实现:
javascript
class ServiceClass {
static create(config) {
// 生产环境实例 - 调用依赖的.create()
const dependency = Dependency.create();
return new ServiceClass(dependency);
}
static createNull(config = {}) {
// 测试环境实例 - 调用依赖的.createNull()
const dependency = Dependency.createNull();
return new ServiceClass(dependency);
}
constructor(dependency) {
// 构造函数接收实例化后的依赖
}
}3. Testing Without Mocks
3. 无Mock测试
- Unit tests: Use - fast, sociable, no external calls to the outside world
.createNull() - Integration tests: Use sparingly - verify live infrastructure
.create() - Configure responses via factory:
Service.createNull({ response: data }) - Track outputs:
service.trackSentEmails() - Verify state and outputs, not interaction patterns
- 单元测试:使用- 快速、社交型,无外部系统调用
.createNull() - 集成测试:谨慎使用- 验证真实基础设施
.create() - 通过工厂配置响应:
Service.createNull({ response: data }) - 跟踪输出:
service.trackSentEmails() - 验证状态与输出,而非交互模式
4. Value Objects
4. 值对象
- Immutable by default - represent data without behavior
- Mutable value objects allowed - use methods for in-memory transformations only
- Infrastructure operations (network, I/O) should take/return value objects, not be methods on them
- 默认不可变 - 仅表示数据,无业务行为
- 允许可变值对象 - 仅用于内存内转换的方法
- 基础设施操作(网络、I/O)应接收/返回值对象,而非将其作为值对象的方法
Nullable algorithm for writing and testing code
编写与测试代码的Nullable算法
Suppose is the class under test. We will use names like , , , etc for the purposes of describing this algorithm, but more descriptive names should be used in the code.
FooFoofooBarClientThe use of static methods and for classes in the code is similar to a dependency injection approach with the addition of creating nulled instances via for unit tests that pretend to talk to the outside world.
Foo.createFoo.createNullFoo.createNull- If is application layer or infrastructure layer
Foo- should be a class (the only exception might be logic code where a function might suffice, in which case call it
Foo)foo - most code should be classes except potentially for logic code
- always use a static .create in order to instantiate:
Foo.create,Bar.create - should set valid production defaults as much as possible reducing the need to pass parameters to
Foo.createFoo.create
- If is is logic layer code:
Foo- if it's stateless, pure functions should be fine so it would be not
fooFoo - if it involves non-I/O non-network in-memory manipulation, a class may be suitable
- use
Foo.create - no need to create since there should be no I/O or network operations
Foo.createNull
- if it's stateless, pure functions should be fine so it would be
- If is a a value object (immutable or mutable)
Val- should be a class
Val - we should have a static but we don't need a
Val.createso some of the procedures below may not apply; this is because value objects shouldn't be doing I/O operations so we don't need nulled versionsVal.createNull - it may not make sense to set defaults for all parameters when creating value objects; so may end up requiring the consumer to specify many or all parameters and should be typed accordingly
Val.create - use a static to create test values; this is to indicate that the value create is a test value; it's fine for
Val.createTestInstanceto set defaults to make tests less verbose.createTestInstance
The following is the algorithm for refactoring and creating new code. It doesn't need to be followed precisely, it is meant to convery the end result.
- Find the code (eg a class ) that you think is the most important to write or test
Foo- we want to be able to test using narrow, sociable unit tests without mocks...
Foo - suppose represents any another class that an instance of
Barneeds to perform its actionsFoo - take class
Foo - (1) ensure calls
Foo.createand passes the instance toBar.creates constructorFoo - (2) or: we pass in a factory that returns an instance of
createBarand let the instance ofBarcreateFooat a later timeBar - take note of calls to instances of within
Barinstance code and refactor to inject them using either (1) or (2)Foo's - if you had to create , repeat this process but with
Bar.createin place ofBarFoo - keep recursing as required
- we want to be able to test
- Now repeat the above but for (if applicable)
Foo.createNull- this means will either (1) call
Foo.createNulland pass toBar.createNulls constructor (2) or it will passFooand this version ofcreateBarwill callcreateBarBar.createNull - the point of is to ensure any infrastructure code is "nulled" and returns a configured response rather than perform an I/O operation (network, disk etc)
.createNull
- this means
- Code that directly makes I/O calls to access an external service or resource such as the network (eg ) or disk etc is infrastrucure layer code and should be wrapped up in a class with
fetchand.createmaking it into a client we can instantiate;.createNull- call this (here) but give it an appropriately descriptive class name eg
Client,HttpClientetcDiskClient - sole purpose is to interface with the outside world but have minimal business logic
Client - we also call an "infrastructure wrapper"
Client - should make an effort to provide data to the rest of the application in the form the application needs and no more
Client - should instantiate a nulled version
Client.createNull - mimics I/O calls (eg mimics calls to fetch)
Client.createNull- by default it should return a default "happy-path" response
- it should take parameters (use a param object) that can configure the responses ("configurable response parameters")
- "configurable response parameters" should configure for possible responses or outcomes we might have to test for; as a human reading the tests I should be able to easily see at a glance what outcome is being configured for in the nulled object; let the implementation within worry about the details
.createNull
- in some situations an "embedded stub" might be appropriate that stubs out a built-in I/O or network operation; the embedded stub should be configued within .
Client.createNull - if is not using a framework like effect-ts to manage errors, then use
Clientand use an object with aneverthrowfield to discriminate the error at compile time; if there is an underlying error from a throw or a rejection, assign it to.type.cause
- call this
- value objects (instances of ) are often returned by
Val'sClient - If is infrastructure or application layer code and its methods contain I/O or network operations, replace these operations with a client and inject an instance of
FooviaClientas per the above algorithm.Foo.create- Where possible make generate a default production-ready instance of
Foo.createwithout the need to specifying anything toClient.Foo.create
- Where possible make
- If the code makes use of a 3rd party library to perform I/O eg tanstack-query / react-query / effect.runPromise etc, then...
- determine if this library provides a test client and use it for the tests
- think about how it should be incorporated into the /
.createarchitecture and let me know.createNull
- Now go back to
Foo- write narrow sociable unit tests against instances of using
FooFoo.createNull
- write narrow sociable unit tests against instances of
- For insfrastructure wrapper code (),
Client- we should test
Client.createNull - in a separate folder designated for live integration tests we should also test
Client.create - these shouldn't be too numerous, we're testing against a live service to test the actual non-nulled code and also that we get back the responses we expect
- these live integration tests should be run in a separate command to the unit tests above
- we should test
- If there are standalone functions that perform I/O or network operations (), favour re-writing
fooas a classfooand treating it as a client / infrastructure wrapper and implementClientaccordingly..createNull - Sometimes we want to know that changes to the outside world occurred in tests, maybe also in a certain order.
- this allows us to keep tests state-based and outcome-focused rather than relying on mocks and testing internal interactions
- Usually this happens in a
Client - should implement an
Clientor a similar mechanism usingEventEmitterand.emit/.on; when some interaction with the outside world (.off) occurs, it should emit an event forXX - should additionally implement a
Clientfunction wheretrackXrepresents the outcome we're trackingX - should return a tracking object (let's call it
.trackXwhich is an instance oftracker)Tracker - should have access to the event emitter in the instance of
TrackerClient - can add itself as a listener for
Trackerusing the event listener and log the events usually in sequential orderX - should get
tracker.stop()to remove itselfTracker - should clear the previously logged data
tracker.clear() - should be a getter to access the data
tracker.data - in some cases, tracking may be useful to the application, in which this event emitter should be exposed publicly; we can still implement a using the above so tests can track the outcomes
Tracker
End of algorithm.
假设是被测类。为描述该算法,我们将使用、、、等名称,但在实际代码中应使用更具描述性的名称。
FooFoofooBarClient代码中类使用静态方法与的方式类似于依赖注入,区别在于通过创建空实例,用于模拟与外部世界交互的单元测试。
Foo.createFoo.createNullFoo.createNull- 如果属于应用层或基础设施层
Foo- 应为类(唯一例外是逻辑层代码,函数可能足够,此时命名为
Foo)foo - 除逻辑层代码外,大部分代码应为类
- 始终使用静态进行实例化:
.createFoo.create,Bar.create - 应设置有效的生产环境默认值,尽可能减少调用
Foo.create时所需传递的参数Foo.create
- 如果属于逻辑层代码:
Foo- 如果是无状态的纯函数,则使用而非
fooFoo - 如果涉及非I/O、非网络的内存内操作,类可能更合适
- 使用
Foo.create - 无需创建,因为逻辑层不应包含I/O或网络操作
Foo.createNull
- 如果是无状态的纯函数,则使用
- 如果是值对象(不可变或可变)
Val- 应为类
Val - 应实现静态,但无需
Val.create,因此以下部分流程可能不适用;这是因为值对象不应执行I/O操作,因此无需空版本Val.createNull - 创建值对象时,可能无需为所有参数设置默认值;因此可能需要消费者指定多个或全部参数,并进行相应的类型定义
Val.create - 使用静态创建测试用值;这用于表明该值是测试用例,
Val.createTestInstance可设置默认值以简化测试代码.createTestInstance
以下是重构与创建新代码的算法。无需严格遵循,仅用于传达最终目标。
- 找到你认为最重要的需编写或测试的代码(如类)
Foo- 我们希望能够使用窄范围、社交型的单元测试,无需Mock即可测试...
Foo - 假设代表
Bar实例执行操作所需的任何其他类Foo - 针对类
Foo - (1) 确保调用
Foo.create并将实例传递给Bar.create的构造函数Foo - (2) 或者:传入工厂函数,该函数返回
createBar实例,让Bar实例在后续创建FooBar - 记录实例代码中对
Foo实例的调用,并使用(1)或(2)的方式重构为依赖注入Bar - 如果必须创建,则重复此流程,将
Bar.create替换为FooBar - 根据需要递归执行
- 我们希望能够使用窄范围、社交型的单元测试,无需Mock即可测试
- 现在针对(如适用)重复上述流程
Foo.createNull- 这意味着将(1)调用
Foo.createNull并传递给Bar.createNull的构造函数,或(2)传递Foo,而此版本的createBar将调用createBarBar.createNull - 的目的是确保所有基础设施代码被“空化”,返回配置好的响应而非执行I/O操作(网络、磁盘等)
.createNull
- 这意味着
- 直接调用I/O以访问外部服务或资源(如网络调用或磁盘操作)的代码属于基础设施层,应包装在包含
fetch与.create的类中,使其成为可实例化的客户端;.createNull- 此处命名为,但实际应使用更具描述性的类名,如
Client、HttpClient等DiskClient - 的唯一目的是与外部世界交互,仅包含最少的业务逻辑
Client - 我们也将称为“基础设施包装器”
Client - 应尽可能以应用所需的形式向应用提供数据,不提供额外内容
Client - 应实例化空版本
Client.createNull - 模拟I/O调用(如模拟fetch调用)
Client.createNull- 默认应返回默认的“正常路径”响应
- 应接收参数(使用参数对象)以配置响应(“可配置响应参数”)
- “可配置响应参数”应针对我们可能需要测试的响应或结果进行配置;作为阅读测试的人员,我应能够一眼看出空对象配置的是哪种结果;内部的实现细节无需关注
.createNull
- 在某些情况下,嵌入式存根可能适合替代内置的I/O或网络操作;嵌入式存根应在中配置
Client.createNull - 如果未使用effect-ts等框架管理错误,则使用
Client,并使用包含neverthrow字段的对象在编译时区分错误;如果存在来自throw或rejection的底层错误,将其分配给.type.cause
- 此处命名为
- 值对象(的实例)通常由
Val返回Client - 如果属于基础设施层或应用层,且其方法包含I/O或网络操作,则将这些操作替换为客户端,并按照上述算法通过
Foo注入Foo.create实例Client- 尽可能让生成默认的生产环境就绪
Foo.create实例,无需向Client传递任何参数Foo.create
- 尽可能让
- 如果代码使用第三方库执行I/O,如tanstack-query / react-query / effect.runPromise等,则...
- 确定该库是否提供测试客户端,并在测试中使用
- 思考如何将其整合到/
.create架构中,并告知我.createNull
- 现在回到
Foo- 使用编写针对
Foo.createNull实例的窄范围社交型单元测试Foo
- 使用
- 对于基础设施包装器代码()
Client- 我们应测试
Client.createNull - 在专门用于实时集成测试的单独文件夹中,还应测试
Client.create - 这类测试不应过多,我们针对真实服务测试实际的非空代码,并验证返回的响应符合预期
- 这些实时集成测试应与上述单元测试分开运行
- 我们应测试
- 如果存在执行I/O或网络操作的独立函数(),建议将
foo重写为类foo,将其视为客户端/基础设施包装器,并相应实现Client.createNull - 有时我们希望在测试中了解外部世界的变化,甚至是变化的顺序
- 这使我们能够保持测试基于状态和结果,而非依赖Mock和测试内部交互
- 通常这发生在中
Client - 应实现
Client或类似机制,使用EventEmitter与.emit/.on;当与外部世界发生某些交互(.off)时,应触发X事件X - 还应实现
Client函数,其中trackX代表我们要跟踪的结果X - 应返回一个跟踪对象(我们称之为
.trackX,即tracker的实例)Tracker - 应能访问
Tracker实例中的事件发射器Client - 可使用事件监听器将自身添加为
Tracker的监听器,并通常按顺序记录事件X - 应让
tracker.stop()移除自身的监听Tracker - 应清除之前记录的数据
tracker.clear() - 应为访问数据的getter
tracker.data - 在某些情况下,跟踪功能可能对应用有用,此时事件发射器应公开;我们仍可使用上述方式实现,以便测试跟踪结果
Tracker
算法结束。
Implementing a New Feature
实现新功能
-
Identify the layer:
- Pure computation? → Logic layer
- External system interaction? → Infrastructure layer
- Coordinating workflow? → Application layer
-
Create the class with factory methods:
- Add with reasonable defaults
static create() - Add for infrastructure and application classes
static createNull() - Use constructor for dependency injection
- Add
-
Implement dependencies through factories:
- calls
.create()on dependencies.create() - calls
.createNull()on dependencies.createNull() - Pass instantiated dependencies to constructor
-
Write tests:
- Use for unit tests
.createNull() - Configure responses via factory:
Service.createNull({ response: data }) - Track outputs:
service.trackOutput() - Verify state and return values
- Use
-
Add integration tests (sparingly):
- Use for a few narrow integration tests
.create() - Verify infrastructure wrappers correctly mimic third-party behavior
- Use
-
确定层级:
- 纯计算?→ 逻辑层
- 与外部系统交互?→ 基础设施层
- 协调工作流?→ 应用层
-
创建带工厂方法的类:
- 添加带有合理默认值的
static create() - 为基础设施层与应用层类添加
static createNull() - 使用构造函数进行依赖注入
- 添加带有合理默认值的
-
通过工厂实现依赖:
- 调用依赖的
.create().create() - 调用依赖的
.createNull().createNull() - 将实例化后的依赖传递给构造函数
-
编写测试:
- 使用进行单元测试
.createNull() - 通过工厂配置响应:
Service.createNull({ response: data }) - 跟踪输出:
service.trackOutput() - 验证状态与返回值
- 使用
-
添加集成测试(谨慎使用):
- 使用编写少量窄范围集成测试
.create() - 验证基础设施包装器是否正确模拟第三方行为
- 使用
Refactoring Existing Code
重构现有代码
-
Separate concerns:
- Extract logic into pure functions/classes
- Wrap external dependencies in infrastructure classes
- Create application layer to orchestrate
-
Add factory methods:
- Replace direct instantiation with
.create() - Add to infrastructure and application classes
.createNull() - Update constructors to receive dependencies
- Replace direct instantiation with
-
Replace mocks with nullables:
- Remove mock setup
- Use instances
.createNull() - Configure responses on nullable infrastructure
-
Refactor tests to be state-based:
- Remove interaction assertions (verify/toHaveBeenCalled)
- Add state and output assertions
- Use event emitters for observable state changes
-
分离关注点:
- 将逻辑提取为纯函数/类
- 将外部依赖包装为基础设施类
- 创建应用层进行协调
-
添加工厂方法:
- 用替换直接实例化
.create() - 为基础设施层与应用层类添加
.createNull() - 更新构造函数以接收依赖
- 用
-
用可空对象替换Mock:
- 移除Mock设置
- 使用实例
.createNull() - 在可空基础设施上配置响应
-
将测试重构为基于状态的测试:
- 移除交互断言(verify/toHaveBeenCalled)
- 添加状态与输出断言
- 使用事件发射器处理可观察的状态变化
Implementation Patterns
实现模式
Pattern: Infrastructure Wrapper
模式:基础设施包装器
Wrap third-party code. Use an embedded stub that mimics the third-party library:
javascript
class HttpClient {
static create() {
return new HttpClient(realHttp); // Real HTTP library
}
static createNull() {
return new HttpClient(new StubbedHttp()); // Embedded stub
}
constructor(http) {
this._http = http;
}
async request(options) {
return await this._http.request(options);
}
}
// Embedded stub - mimics third-party library interface
class StubbedHttp {
async request(options) {
return { status: 200, body: {} };
}
}包装第三方代码。使用嵌入式存根模拟第三方库:
javascript
class HttpClient {
static create() {
return new HttpClient(realHttp); // 真实HTTP库
}
static createNull() {
return new HttpClient(new StubbedHttp()); // 嵌入式存根
}
constructor(http) {
this._http = http;
}
async request(options) {
return await this._http.request(options);
}
}
// 嵌入式存根 - 模拟第三方库接口
class StubbedHttp {
async request(options) {
return { status: 200, body: {} };
}
}Pattern: Logic Sandwich
模式:逻辑三明治
Application code: Read → Process → Write
javascript
async transfer(fromId, toId, amount) {
// Read from infrastructure
const accounts = await this._repo.getAccounts(fromId, toId);
// Process with logic
const result = this._logic.transfer(accounts, amount);
// Write through infrastructure
await this._repo.saveAccounts(result);
}应用层代码:读取 → 处理 → 写入
javascript
async transfer(fromId, toId, amount) {
// 从基础设施读取数据
const accounts = await this._repo.getAccounts(fromId, toId);
// 使用逻辑层处理
const result = this._logic.transfer(accounts, amount);
// 通过基础设施写入数据
await this._repo.saveAccounts(result);
}Pattern: Event Emitters for State
模式:用于状态的事件发射器
Expose state changes for testing:
javascript
class Account extends EventEmitter {
deposit(amount) {
this._balance += amount;
this.emit('balance-changed', { balance: this._balance });
}
}
// Test by observing events
account.on('balance-changed', (event) => events.push(event));公开状态变化以用于测试:
javascript
class Account extends EventEmitter {
deposit(amount) {
this._balance += amount;
this.emit('balance-changed', { balance: this._balance });
}
}
// 通过观察事件进行测试
account.on('balance-changed', (event) => events.push(event));Detailed Reference
详细参考
For comprehensive implementation details, see nullables-guide.md:
- Complete A-Frame architecture details
- Nullables pattern implementation
- Value objects (immutable and mutable)
- Testing strategies and examples
- Infrastructure wrappers and embedded stubs
- Configurable responses and output tracking
- Event emitters for state-based tests
- Implementation checklist
如需完整的实现细节,请查看 nullables-guide.md:
- 完整的A-Frame架构细节
- Nullables模式实现
- 值对象(不可变与可变)
- 测试策略与示例
- 基础设施包装器与嵌入式存根
- 可配置响应与输出跟踪
- 用于基于状态测试的事件发射器
- 实现检查清单