开始一个新游戏

在本节中,我们将测试驱动一个 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 请求和响应转换为领域层中的对象。

一如既往,我们从创建一个测试类开始:

  1. 首先,我们编写测试类。我们将其命名为 WordzEndpointTest,并将其放在 com.wordz.adapters.api 包中。

    package com.wordz.adapters.api;
    
    public class WordzEndpointTest {
    }

    之所以包含这个包,是因为它是我们六边形架构的一部分。在这个 Web 适配器中的代码可以使用来自领域模型的任何内容,而领域模型本身并不知道这个 Web 适配器的存在。

    我们的第一个测试将是启动一个新游戏:

    @Test
    void startGame() {
    }
  2. 这个测试需要捕捉我们预期的 Web API 周围的设计决策。一个决策是,当游戏成功启动时,我们将返回一个简单的 204 No Content HTTP 状态码。我们将从断言开始,以捕捉这一决策:

    @Test
    void startGame() {
       HttpResponse res;
       assertThat(res)
           .hasStatusCode(HttpStatus.NO_CONTENT.code);
    }
  3. 接下来,我们编写 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);
    }
  4. 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 这一细节移到配置中。但现在,为了让测试在本地机器上运行,这样处理是可以的。

  5. 我们可以运行集成测试并确认它失败:

    image 2025 01 12 21 27 50 843

    不出所料,此测试失败是因为它无法连接到 HTTP 服务器。解决这个问题是我们的下一个任务。

创建我们的 HTTP 服务器

失败的测试使我们能够测试驱动实现 HTTP 服务器的代码。我们将使用 Molecule 库来提供 HTTP 服务:

  1. 添加一个端点类,我们将其命名为 WordzEndpoint 类:

    @Test
    void startGame() throws IOException, InterruptedException {
       var endpoint
           = new WordzEndpoint("localhost", 8080);
    }

    传递给 WordzEndpoint 构造函数的两个参数定义了 Web 端点将运行的主机和端口。

  2. 我们使用 IDE 生成类:

       package com.wordz.adapters.api;
    
       public class WordzEndpoint {
           public WordzEndpoint(String host, int port) {
           }
       }

    在这种情况下,我们不会将主机和端口详细信息存储在字段中。相反,我们将使用 Molecule 库中的类启动一个 WebServer

  3. 我们使用 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 服务器添加路由,请执行以下操作:

  1. 测试驱动 /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();
       }
    }
  2. 运行 WordzEndpointTest 集成测试

    image 2025 01 12 21 33 54 431
    Figure 1. Figure 15.2 – An incorrect HTTP status

    测试如预期般失败。我们已经取得了进展,因为测试现在因不同的原因而失败。我们现在可以连接到 Web 端点,但它没有返回正确的 HTTP 响应。我们的下一个任务是将此 Web 端点连接到领域层代码,并采取相关操作来启动游戏。

连接到领域层

我们的下一个任务是接收 HTTP 请求并将其转换为领域层调用。这涉及使用 Google Gson 库将 JSON 请求数据解析为 Java 对象,然后将该响应数据发送到 Wordz 类端口:

  1. 添加代码以调用作为类 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);
       }
    }
  2. 我们需要将类 Wordz 领域对象提供给类 WordzEndpoint。我们使用依赖注入将其注入到构造函数中。

    public class WordzEndpoint {
       private final WebServer server;
       private final Wordz wordz;
    
       public WordzEndpoint(Wordz wordz,
                            String host, int port) {
           this.wordz = wordz;
       }
    }
  3. 接下来,我们需要添加启动游戏的代码。为此,我们首先从请求体中的 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");
    }
  4. 现在我们可以运行测试,但它失败了:

    image 2025 01 12 21 42 26 286
    Figure 2. Figure 15.3 – An incorrect HTTP response

    失败的原因是 wordz.newGame() 返回了 false。需要设置模拟对象以返回 true

  5. mockWordz 存根返回正确的值。

@Test
void startsGame() throws IOException, InterruptedException {
   var endpoint
       = new WordzEndpoint(mockWordz,
           "localhost", 8080);

   when(mockWordz.newGame(eq(PLAYER)))
       .thenReturn(true);
}
  1. 然后,运行测试:

image 2025 01 12 21 44 09 784
Figure 3. Figure 15.4 – The test passes

集成测试通过了。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,以表示该玩家的游戏已经在进行中,无法为他们启动新游戏:

  1. 编写测试代码,当游戏已经在进行时返回 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);
    }
  2. 接下来,运行测试。由于我们尚未编写实现代码,因此测试应该失败。

    image 2025 01 12 21 47 33 753
    Figure 4. Figure 15.5 – A failing test
  3. 通过测试驱动代码,报告游戏无法重新启动。

    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);
       }
    }
  4. 再次运行测试

    image 2025 01 12 21 49 03 169
    Figure 5. Figure 15. 6 – The test passes

    现在,测试在单独运行时通过,因为实现代码已经就位。让我们运行所有 WordzEndpointTests 测试以再次检查我们的进展。

  5. 运行所有 WordzEndpointTests

    image 2025 01 12 21 49 37 822
    Figure 6. Figure 15.7 – Test failure due to restarting the server

    出乎意料的是,当测试一个接一个运行时,测试失败了。

修复意外失败的测试

当我们运行所有测试时,它们现在失败了。之前单独运行时,所有测试都正确通过。最近的更改显然破坏了某些东西。我们在某个时刻失去了测试隔离。此错误消息表明Web服务器在同一端口上被启动了两次,这是不可能的。

解决方案是在每次测试后停止 Web 服务器,或者只为所有测试启动一次 Web 服务器。由于这是一个长期运行的微服务,只启动一次似乎是更好的选择:

  1. 添加一个 @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 中的所有测试都通过了。

  2. 在所有测试再次通过后,我们可以考虑重构代码。提取一个 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。我们将在此省略该代码。在下一节中,我们将继续测试驱动猜测目标单词的代码。