开始一个新游戏
在本节中,我们将测试驱动一个 Web 适配器,该适配器将为我们的领域模型提供一个 HTTP API。外部 Web 客户端将能够向此端点发送 HTTP 请求,以触发领域模型中的操作,从而让我们可以玩游戏。API 将返回适当的 HTTP 响应,指示提交猜测的得分,并在游戏结束时进行报告。
以下开源库将帮助我们编写代码:
-
Molecule:这是一个轻量级的 HTTP 框架。
-
Undertow:这是一个轻量级的 HTTP Web 服务器,为 Molecule 框架提供支持。
-
GSON:这是一个 Google 库,用于在 Java 对象和 JSON 结构化数据之间进行转换。
为了开始构建,我们首先将所需的库作为依赖项添加到 build.gradle
文件中。然后,我们可以开始为 HTTP 端点编写集成测试,并测试驱动其实现。
将所需的库添加到项目中
我们需要将 Molecule、Undertow 和 Gson 这三个库添加到 build.gradle
文件中,然后才能使用它们:
将以下代码添加到 build.gradle
文件中:
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
testImplementation 'org.assertj:assertj-core:3.22.0'
testImplementation 'org.mockito:mockito-core:4.8.0'
testImplementation 'org.mockito:mockito-junit-jupiter:4.8.0'
testImplementation 'com.github.database-rider:rider-core:1.35.0'
testImplementation 'com.github.database-rider:rider-junit5:1.35.0'
implementation 'org.postgresql:postgresql:42.5.0'
implementation 'org.jdbi:jdbi3-core:3.34.0'
implementation 'org.apache.commons:commons-lang3:3.12.0'
implementation 'com.vtence.molecule:molecule:0.15.0'
implementation 'io.thorntail:undertow:2.7.0.Final'
implementation 'com.google.code.gson:gson:2.10'
}
编写失败的测试
我们将按照正常的 TDD 周期来创建我们的 Web 适配器。在为适配器层中的对象编写测试时,我们必须专注于测试领域层对象与外部系统通信之间的转换。我们的适配器层将使用 Molecule HTTP 框架来处理 HTTP 请求和响应。
由于我们使用了六边形架构并从领域层开始,我们已经知道游戏逻辑是有效的。这个测试的目标是证明 Web 适配器层正在履行其职责,即将 HTTP 请求和响应转换为领域层中的对象。
一如既往,我们从创建一个测试类开始:
-
首先,我们编写测试类。我们将其命名为
WordzEndpointTest
,并将其放在com.wordz.adapters.api
包中。package com.wordz.adapters.api; public class WordzEndpointTest { }
之所以包含这个包,是因为它是我们六边形架构的一部分。在这个 Web 适配器中的代码可以使用来自领域模型的任何内容,而领域模型本身并不知道这个 Web 适配器的存在。
我们的第一个测试将是启动一个新游戏:
@Test void startGame() { }
-
这个测试需要捕捉我们预期的 Web API 周围的设计决策。一个决策是,当游戏成功启动时,我们将返回一个简单的
204 No Content HTTP
状态码。我们将从断言开始,以捕捉这一决策:@Test void startGame() { HttpResponse res; assertThat(res) .hasStatusCode(HttpStatus.NO_CONTENT.code); }
-
接下来,我们编写 Act 步骤。这里的动作是外部 HTTP 客户端向我们的 Web 端点发送请求。为了实现这一点,我们使用 Java 自带的内置 HTTP 客户端。我们安排代码发送请求,然后丢弃任何 HTTP 响应体,因为我们的设计不返回响应体:
@Test void startGame() throws IOException, InterruptedException { var httpClient = HttpClient.newHttpClient(); HttpResponse res = httpClient.send(req, HttpResponse.BodyHandlers.discarding()); assertThat(res) .hasStatusCode(HttpStatus.NO_CONTENT.code); }
-
Arrange
步骤是我们捕捉有关发送 HTTP 请求的决策的地方。为了开始一个新游戏,我们需要一个Player
对象来识别玩家。我们将这个对象作为 JSON 数据发送在请求体中。该请求将导致服务器上的状态变化,因此我们选择使用 HTTP POST 方法来表示这一点。最后,我们选择一个路径为/start
的路由。@Test private static final Player PLAYER = new Player("alan2112"); void startGame() throws IOException, InterruptedException { var req = HttpRequest.newBuilder() .uri(URI.create("http://localhost:8080/start")) .POST(HttpRequest.BodyPublishers .ofString(new Gson().toJson(PLAYER))) .build(); var httpClient = HttpClient.newHttpClient(); HttpResponse res = httpClient.send(req, HttpResponse.BodyHandlers.discarding()); assertThat(res) .hasStatusCode(HttpStatus.NO_CONTENT.code); }
我们看到使用
Gson
库将Player
对象转换为其 JSON 表示形式。我们还看到构造并发送了一个POST
请求到localhost
的/start
路径。最终,我们希望将localhost
这一细节移到配置中。但现在,为了让测试在本地机器上运行,这样处理是可以的。 -
我们可以运行集成测试并确认它失败:
不出所料,此测试失败是因为它无法连接到 HTTP 服务器。解决这个问题是我们的下一个任务。
创建我们的 HTTP 服务器
失败的测试使我们能够测试驱动实现 HTTP 服务器的代码。我们将使用 Molecule
库来提供 HTTP 服务:
-
添加一个端点类,我们将其命名为
WordzEndpoint
类:@Test void startGame() throws IOException, InterruptedException { var endpoint = new WordzEndpoint("localhost", 8080); }
传递给
WordzEndpoint
构造函数的两个参数定义了 Web 端点将运行的主机和端口。 -
我们使用 IDE 生成类:
package com.wordz.adapters.api; public class WordzEndpoint { public WordzEndpoint(String host, int port) { } }
在这种情况下,我们不会将主机和端口详细信息存储在字段中。相反,我们将使用 Molecule 库中的类启动一个
WebServer
。 -
我们使用 Molecule 库创建
WebServer
:package com.wordz.adapters.api; import com.vtence.molecule.WebServer; public class WordzEndpoint { private final WebServer server; public WordzEndpoint(String host, int port) { server = WebServer.create(host, port); } }
上述代码足以启动一个 HTTP 服务器并允许测试连接到它。我们的 HTTP 服务器在玩游戏方面没有任何实际作用。我们需要为此服务器添加一些路由以及响应它们的代码。
向 HTTP 服务器添加路由
为了使 HTTP 端点有用,它必须响应 HTTP 命令,解释它们,并将它们作为命令发送到我们的领域层。作为设计决策,我们决定以下内容:
-
必须调用
/start
路由来启动游戏。 -
我们将使用 HTTP POST 方法。
-
我们将在 POST 主体中以 JSON 数据的形式标识游戏属于哪个玩家。
要为 HTTP 服务器添加路由,请执行以下操作:
-
测试驱动
/start
路由,为了小步前进,最初我们将返回一个NOT_IMPLEMENTED
HTTP 响应代码:public class WordzEndpoint { private final WebServer server; public WordzEndpoint(String host, int port) { server = WebServer.create(host, port); try { server.route(new Routes() {{ post("/start") .to(request -> startGame(request)); }}); } catch (IOException ioe) { throw new IllegalStateException(ioe); } } private Response startGame(Request request) { return Response .of(HttpStatus.NOT_IMPLEMENTED) .done(); } }
-
运行
WordzEndpointTest
集成测试Figure 1. Figure 15.2 – An incorrect HTTP status测试如预期般失败。我们已经取得了进展,因为测试现在因不同的原因而失败。我们现在可以连接到 Web 端点,但它没有返回正确的 HTTP 响应。我们的下一个任务是将此 Web 端点连接到领域层代码,并采取相关操作来启动游戏。
连接到领域层
我们的下一个任务是接收 HTTP 请求并将其转换为领域层调用。这涉及使用 Google Gson 库将 JSON 请求数据解析为 Java 对象,然后将该响应数据发送到 Wordz
类端口:
-
添加代码以调用作为类 Wordz 实现的领域层端口。我们将使用
Mockito
创建这个对象的测试替身。这使我们能够仅测试 Web 端点代码,与其他代码解耦。@ExtendWith(MockitoExtension.class) public class WordzEndpointTest { @Mock private Wordz mockWordz; @Test void startGame() throws IOException, InterruptedException { var endpoint = new WordzEndpoint(mockWordz, "localhost", 8080); } }
-
我们需要将类
Wordz
领域对象提供给类WordzEndpoint
。我们使用依赖注入将其注入到构造函数中。public class WordzEndpoint { private final WebServer server; private final Wordz wordz; public WordzEndpoint(Wordz wordz, String host, int port) { this.wordz = wordz; } }
-
接下来,我们需要添加启动游戏的代码。为此,我们首先从请求体中的 JSON 数据中提取
Player
对象,用以标识哪个玩家要开始游戏。然后我们调用wordz.newGame()
方法。如果成功,我们返回 HTTP 状态码204 No Content
,表示成功。private Response startGame(Request request) { try { Player player = new Gson().fromJson(request.body(), Player.class); boolean isSuccessful = wordz.newGame(player); if (isSuccessful) { return Response .of(HttpStatus.NO_CONTENT) .done(); } } catch (IOException e) { throw new RuntimeException(e); } throw new UnsupportedOperationException("Not implemented"); }
-
现在我们可以运行测试,但它失败了:
Figure 2. Figure 15.3 – An incorrect HTTP response失败的原因是
wordz.newGame()
返回了false
。需要设置模拟对象以返回true
。 -
从
mockWordz
存根返回正确的值。
@Test
void startsGame() throws IOException, InterruptedException {
var endpoint
= new WordzEndpoint(mockWordz,
"localhost", 8080);
when(mockWordz.newGame(eq(PLAYER)))
.thenReturn(true);
}
-
然后,运行测试:

集成测试通过了。HTTP 请求已被接收,调用了领域层代码以启动新游戏,并返回了 HTTP 响应。下一步是考虑重构。
重构启动游戏代码
像往常一样,一旦测试通过,我们会考虑是否需要进行重构。
将测试代码重构以简化新测试的编写是值得的,通过将通用代码整理到一个地方:
@ExtendWith(MockitoExtension.class)
public class WordzEndpointTest {
@Mock
private Wordz mockWordz;
private WordzEndpoint endpoint;
private static final Player PLAYER
= new Player("alan2112");
private final HttpClient httpClient
= HttpClient.newHttpClient();
@BeforeEach
void setUp() {
endpoint = new WordzEndpoint(mockWordz,
"localhost", 8080);
}
@Test
void startsGame() throws IOException, InterruptedException {
when(mockWordz.newGame(eq(PLAYER)))
.thenReturn(true);
var req = requestBuilder("start")
.POST(asJsonBody(PLAYER))
.build();
var res
= httpClient.send(req,
HttpResponse.BodyHandlers.discarding());
assertThat(res)
.hasStatusCode(HttpStatus.NO_CONTENT.code);
}
private HttpRequest.Builder requestBuilder(String path) {
return HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8080/" + path));
}
private HttpRequest.BodyPublisher asJsonBody(Object source) {
return HttpRequest.BodyPublishers
.ofString(new Gson().toJson(source));
}
}
在这个重构中,我们将常见的测试代码提取到辅助方法中,例如 requestBuilder()
和 asJsonBody()
,以减少重复代码并提高可读性。这样,未来的测试可以更简洁地编写。
处理启动游戏时的错误
我们的设计决策之一是,当游戏正在进行时,玩家不能重新开始游戏。我们需要测试驱动这种行为。我们选择返回 HTTP 状态码 409 Conflict
,以表示该玩家的游戏已经在进行中,无法为他们启动新游戏:
-
编写测试代码,当游戏已经在进行时返回 409 冲突 状态码。
@Test void rejectsRestart() throws Exception { when(mockWordz.newGame(eq(PLAYER))) .thenReturn(false); var req = requestBuilder("start") .POST(asJsonBody(PLAYER)) .build(); var res = httpClient.send(req, HttpResponse.BodyHandlers.discarding()); assertThat(res) .hasStatusCode(HttpStatus.CONFLICT.code); }
-
接下来,运行测试。由于我们尚未编写实现代码,因此测试应该失败。
Figure 4. Figure 15.5 – A failing test -
通过测试驱动代码,报告游戏无法重新启动。
private Response startGame(Request request) { try { Player player = new Gson().fromJson(request.body(), Player.class); boolean isSuccessful = wordz.newGame(player); if (isSuccessful) { return Response .of(HttpStatus.NO_CONTENT) .done(); } return Response .of(HttpStatus.CONFLICT) .done(); } catch (IOException e) { throw new RuntimeException(e); } }
-
再次运行测试
Figure 5. Figure 15. 6 – The test passes现在,测试在单独运行时通过,因为实现代码已经就位。让我们运行所有
WordzEndpointTests
测试以再次检查我们的进展。 -
运行所有
WordzEndpointTests
Figure 6. Figure 15.7 – Test failure due to restarting the server出乎意料的是,当测试一个接一个运行时,测试失败了。
修复意外失败的测试
当我们运行所有测试时,它们现在失败了。之前单独运行时,所有测试都正确通过。最近的更改显然破坏了某些东西。我们在某个时刻失去了测试隔离。此错误消息表明Web服务器在同一端口上被启动了两次,这是不可能的。
解决方案是在每次测试后停止 Web 服务器,或者只为所有测试启动一次 Web 服务器。由于这是一个长期运行的微服务,只启动一次似乎是更好的选择:
-
添加一个
@BeforeAll
注解,以便只启动一次 HTTP 服务器。@BeforeAll void setUp() { mockWordz = mock(Wordz.class); endpoint = new WordzEndpoint(mockWordz, "localhost", 8080); }
我们将
@BeforeEach
注解更改为@BeforeAll
注解,以使端点创建仅在每次测试类运行时发生一次。为了支持这一点,我们还必须创建模拟对象,并在测试本身上使用注解来控制对象的生命周期:@ExtendWith(MockitoExtension.class) @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class WordzEndpointTest { }
现在,
WordzEndpointTest
中的所有测试都通过了。 -
在所有测试再次通过后,我们可以考虑重构代码。提取一个
extractPlayer()
方法可以提高可读性。我们还可以使条件 HTTP 状态码更简洁:
private Response startGame(Request request) {
try {
Player player = extractPlayer(request);
boolean isSuccessful = wordz.newGame(player);
HttpStatus status
= isSuccessful ?
HttpStatus.NO_CONTENT :
HttpStatus.CONFLICT;
return Response
.of(status)
.done();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private Player extractPlayer(Request request) throws IOException {
return new Gson().fromJson(request.body(), Player.class);
}
我们现在已经完成了启动游戏所需的主要编码部分。为了处理剩余的错误情况,我们现在可以测试驱动代码,如果无法从 JSON 有效负载中读取 Player
对象,则返回 400 BAD REQUEST
。我们将在此省略该代码。在下一节中,我们将继续测试驱动猜测目标单词的代码。