在軟件開發領域,最大的錯誤之一就是交付客户"精確"想要的東西。這聽起來可能像陳詞濫調,但即使在行業摸爬滾打數十年後,這個問題依然存在。一個更有效的方法是從關注業務需求開始測試。

行為驅動開發【Behavior-driven development】(BDD)是一種強調行為和領域術語(也稱為統一語言)的軟件開發方法論。它使用共享的自然語言,從用户的角度定義和測試軟件行為。BDD 建立在測試驅動開發【test-driven development】(TDD)的基礎上,專注於與業務相關的場景。這些場景以純語言規範的形式編寫,可以自動化成測試,同時也充當活文檔。

這種方法促進了技術和非技術利益相關者之間的共識,確保軟件滿足用户需求,並有助於減少返工和開發時間。在本文中,我們將進一步探討這種方法論,並討論如何使用 Oracle NoSQL 和 Java 來實現它。

BDD 與 DDD 如何協同工作

乍看之下,行為驅動開發(BDD)和領域驅動設計(DDD)似乎解決的是不同的問題——一個側重於測試,另一個側重於建模。然而,它們共享相同的哲學基礎:確保軟件真實反映其所服務的業務領域

DDD,由 Eric Evans 在其 2003 年具有開創性的著作《領域驅動設計:軟件核心複雜性的應對之道》中提出,教導我們圍繞業務概念(實體、值對象、聚合和限界上下文)來建模軟件。其力量在於使用統一語言,這是一種連接開發人員和領域專家的共享詞彙表。

BDD,由 Dan North 在幾年後提出,是這一思想自然而然的延伸。它將統一語言引入測試過程,將業務規則轉化為可執行的規範。DDD 定義了系統應該表示什麼,而 BDD 則根據該模型驗證系統的行為方式

當結合使用時,DDD 和 BDD 形成了一個持續的反饋循環:

  • DDD 塑造了捕獲業務邏輯的領域模型
  • BDD 確保系統行為隨着時間的推移與該模型保持一致。

在實踐中,這種協同作用意味着您可以編寫與聚合(如 Room 和 Reservation)直接相關的特性場景——例如"當我預訂一個 VIP 房間時,系統應將其標記為不可用"。這些測試成為開發人員和利益相關者的活文檔,確保您的領域始終與真實的業務需求保持一致。

如果您想深入探索這種結合,我的著作《Domain-Driven Design with Java》詳細闡述了這些原則。它展示瞭如何在現代 Java 應用程序中使用 Jakarta EE、Spring 和雲技術應用 DDD 模式,為統一架構和行為提供了實踐基礎。

總之,DDD 和 BDD 共同彌合了理解業務與證明其可行之間的差距——將軟件從技術製品轉變為領域本身的忠實表達。

代碼實現

在本示例中,我們將使用企業級 Java 和 Oracle NoSQL 數據庫生成一個簡單的酒店管理應用程序。

第一步是創建項目。由於我們使用的是 Java SE,我們可以使用以下 Maven 命令生成它:

mvn archetype:generate                     \
"-DarchetypeGroupId=io.cucumber"           \
"-DarchetypeArtifactId=cucumber-archetype" \
"-DarchetypeVersion=7.30.0"                \
"-DgroupId=org.soujava.demos.hotel"        \
"-DartifactId=behavior-driven-development" \
"-Dpackage=org.soujava.demos"              \
"-Dversion=1.0.0-SNAPSHOT"                 \
"-DinteractiveMode=false"

下一步是引入 Eclipse JNoSQLOracle NoSQL,以及 Jakarta EE 組件的實現:CDI、JSON 和 Eclipse MicroProfile 實現。

您可以找到完整的 pom.xml 文件。

初始項目準備就緒後,我們將從創建測試開始。

請記住,BDD 是 TDD 的擴展,它包含了統一語言——領域和業務之間的共享詞彙。

功能: 管理酒店房間

  場景: 註冊一個新房間
    假設 酒店管理系統正在運行
    當 我註冊一個號碼為 203 的房間
    那麼 號碼為 203 的房間應該出現在房間列表中

  場景: 註冊多個房間
    假設 酒店管理系統正在運行
    當 我註冊以下房間:
      | number | type      | status             | cleanStatus |
      | 101    | STANDARD  | AVAILABLE          | CLEAN       |
      | 102    | SUITE     | RESERVED           | DIRTY       |
      | 103    | VIP_SUITE | UNDER_MAINTENANCE  | CLEAN       |
    那麼 系統中應該有 3 個可用房間

  場景: 更改房間狀態
    假設 酒店管理系統正在運行
    並且 一個號碼為 101 的房間已註冊為 AVAILABLE
    當 我將房間 101 標記為 OUT_OF_SERVICE
    那麼 房間 101 應被標記為 OUT_OF_SERVICE

Maven 項目完成後,讓我們進入下一步,即創建建模和存儲庫。如前所述,我們將專注於房間管理。因此,我們的下一個目標是確保之前定義的 BDD 測試通過。讓我們從實現領域模型和存儲庫開始:

public enum CleanStatus {
    CLEAN,       // 清潔
    DIRTY,       // 髒污
    INSPECTION_NEEDED // 需要檢查
}

public enum RoomStatus {
    AVAILABLE,         // 可用
    RESERVED,          // 已預訂
    UNDER_MAINTENANCE, // 維護中
    OUT_OF_SERVICE     // 停止服務
}

public enum RoomType {
    STANDARD,  // 標準間
    DELUXE,    // 豪華間
    SUITE,     // 套房
    VIP_SUITE  // VIP套房
}

@Entity
public class Room {

    @Id
    private String id;

    @Column
    private int number; // 房間號

    @Column
    private RoomType type; // 房間類型

    @Column
    private RoomStatus status; // 房間狀態

    @Column
    private CleanStatus cleanStatus; // 清潔狀態

    @Column
    private boolean smokingAllowed; // 允許吸煙

    @Column
    private boolean underMaintenance; // 處於維護狀態

}

有了模型,下一步是創建企業級 Java 與作為非關係型數據庫的 Oracle NoSQL 之間的橋樑。我們可以使用 Jakarta Data 非常輕鬆地完成,它只有一個存儲庫接口,所以我們不需要擔心實現。

@Repository
public interface RoomRepository {

    @Query("FROM Room")
    List<Room> findAll();

    @Save
    Room save(Room room);

    void deleteBy();

    Optional<Room> findByNumber(Integer number);
}

項目完成後,下一步是準備測試環境,首先提供一個數據庫實例用於測試。多虧了 Testcontainers,我們可以輕鬆啓動一個隔離的 Oracle NoSQL 實例來運行我們的測試。

public enum DatabaseContainer {

    INSTANCE;

    private final GenericContainer<?> container = new GenericContainer<>
            (DockerImageName.parse("ghcr.io/oracle/nosql:latest-ce"))
            .withExposedPorts(8080);

    {
        container.start();
    }
    public DatabaseManager get(String database) {
        DatabaseManagerFactory factory = managerFactory();
        return factory.apply(database);
    }

    public DatabaseManagerFactory managerFactory() {
        var configuration = DatabaseConfiguration.getConfiguration();
        Settings settings = Settings.builder()
                .put(OracleNoSQLConfigurations.HOST, host())
                .build();
        return configuration.apply(settings);
    }

    public String host() {
        return "http://" + container.getHost() + ":" + container.getFirstMappedPort();
    }
}

之後,我們將創建一個與 @Alternative CDI 註解集成的生產者。此配置指導 CDI 如何提供數據庫實例——在本例中是由 Testcontainers 管理的實例:

@ApplicationScoped
@Alternative
@Priority(Interceptor.Priority.APPLICATION)
public class ManagerSupplier implements Supplier<DatabaseManager> {

    @Produces
    @Database(DatabaseType.DOCUMENT)
    @Default
    public DatabaseManager get() {
        return DatabaseContainer.INSTANCE.get("hotel");
    }

}

藉助 Cucumber,我們可以定義一個將類注入到 Cucumber 測試上下文中的 ObjectFactory。由於我們使用 CDI 並以 Weld 作為實現,我們將創建一個自定義的 WeldCucumberObjectFactory 來無縫集成這兩種技術。

public class WeldCucumberObjectFactory implements ObjectFactory {

    private Weld weld;
    private WeldContainer container;

    @Override
    public void start() {
        weld = new Weld();
        container = weld.initialize();
    }

    @Override
    public void stop() {
        if (weld != null) {
            weld.shutdown();
        }
    }

    @Override
    public boolean addClass(Class<?> stepClass) {
        return true;
    }

    @Override
    public <T> T getInstance(Class<T> type) {
        return (T) container.select(type).get();
    }
}

一個重要提示:此設置作為 SPI(服務提供者接口)工作。因此,您必須創建以下文件:

src/test/resources/META-INF/services/io.cucumber.core.backend.ObjectFactory

內容如下:

org.soujava.demos.hotels.config.WeldCucumberObjectFactory

我們將讓 Mapper 將我們的數據錶轉換為所有模型中的 Room 對象。

@ApplicationScoped
public class RoomDataTableMapper {

    @DataTableType
    public Room roomEntry(Map<String, String> entry) {
        return Room.builder()
                .number(Integer.parseInt(entry.get("number")))
                .type(RoomType.valueOf(entry.get("type")))
                .status(RoomStatus.valueOf(entry.get("status")))
                .cleanStatus(CleanStatus.valueOf(entry.get("cleanStatus")))
                .build();
    }
}

整個測試基礎設施完成後,下一步是設計包含我們實際測試的 Step 測試類。

@ApplicationScoped
public class HotelRoomSteps {

    @Inject
    private RoomRepository repository;

    @Before
    public void cleanDatabase() {
        repository.deleteBy();
    }

    @Given("the hotel management system is operational")
    public void theHotelManagementSystemIsOperational() {
        Assertions.assertThat(repository).as("RoomRepository 應該已初始化").isNotNull();
    }

    @When("I register a room with number {int}")
    public void iRegisterARoomWithNumber(Integer number) {
        Room room = Room.builder()
                .number(number)
                .type(RoomType.STANDARD)
                .status(RoomStatus.AVAILABLE)
                .cleanStatus(CleanStatus.CLEAN)
                .build();
        repository.save(room);
    }

    @Then("the room with number {int} should appear in the room list")
    public void theRoomWithNumberShouldAppearInTheRoomList(Integer number) {
        List<Room> rooms = repository.findAll();
        Assertions.assertThat(rooms)
                .extracting(Room::getNumber)
                .contains(number);
    }

    @When("I register the following rooms:")
    public void iRegisterTheFollowingRooms(List<Room> rooms) {
        rooms.forEach(repository::save);
    }

    @Then("there should be {int} rooms available in the system")
    public void thereShouldBeRoomsAvailableInTheSystem(int expectedCount) {
        List<Room> rooms = repository.findAll();
        Assertions.assertThat(rooms).hasSize(expectedCount);
    }

    @Given("a room with number {int} is registered as {word}")
    public void aRoomWithNumberIsRegisteredAs(Integer number, String statusName) {
        RoomStatus status = RoomStatus.valueOf(statusName);
        Room room = Room.builder()
                .number(number)
                .type(RoomType.STANDARD)
                .status(status)
                .cleanStatus(CleanStatus.CLEAN)
                .build();
        repository.save(room);
    }

    @When("I mark the room {int} as {word}")
    public void iMarkTheRoomAs(Integer number, String newStatusName) {
        RoomStatus newStatus = RoomStatus.valueOf(newStatusName);
        Optional<Room> roomOpt = repository.findByNumber(number);

        Assertions.assertThat(roomOpt)
                .as("房間 %s 應該存在", number)
                .isPresent();

        Room updatedRoom = roomOpt.orElseThrow();
        updatedRoom.update(newStatus); // 假設 Room 類有 update 方法

        repository.save(updatedRoom);
    }

    @Then("the room {int} should be marked as {word}")
    public void theRoomShouldBeMarkedAs(Integer number, String expectedStatusName) {
        RoomStatus expectedStatus = RoomStatus.valueOf(expectedStatusName);
        Optional<Room> roomOpt = repository.findByNumber(number);

        Assertions.assertThat(roomOpt)
                .as("房間 %s 應該存在", number)
                .isPresent()
                .get()
                .extracting(Room::getStatus)
                .isEqualTo(expectedStatus);
    }
}

是時候執行測試了:

mvn clean test

您可以看到結果:

INFO: Connecting to Oracle NoSQL database at http://localhost:61325 using ON_PREMISES deployment type
  ✔ Given the hotel management system is operational      # org.soujava.demos.hotels.HotelRoomSteps.theHotelManagementSystemIsOperational()
  ✔ And a room with number 101 is registered as AVAILABLE # org.soujava.demos.hotels.HotelRoomSteps.aRoomWithNumberIsRegisteredAs(java.lang.Integer,java.lang.String)
  ✔ When I mark the room 101 as OUT_OF_SERVICE            # org.soujava.demos.hotels.HotelRoomSteps.iMarkTheRoomAs(java.lang.Integer,java.lang.String)
  ✔ Then the room 101 should be marked as OUT_OF_SERVICE  # org.soujava.demos.hotels.HotelRoomSteps.theRoomShouldBeMarkedAs(java.lang.Integer,java.lang.String)
Oct 21, 2025 6:18:43 PM org.jboss.weld.environment.se.WeldContainer shutdown
INFO: WELD-ENV-002001: Weld SE container fc4b3b51-fba8-4ea6-9cef-42bcee97d220 shut down
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.231 s -- in org.soujava.demos.hotels.RunCucumberTest
[INFO] Running org.soujava.demos.hotels.MongoDBTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.007 s -- in org.soujava.demos.hotels.MongoDBTest
[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO]

結論

通過結合領域驅動設計(DDD)和行為驅動開發(BDD),開發人員可以超越技術正確性,構建真正反映業務意圖的軟件。DDD 為領域提供了結構,確保模型精確地捕捉現實世界的概念,而 BDD 則通過用業務本身的語言編寫的清晰、可測試的場景,確保這些模型按預期運行。

在本文中,您學習瞭如何使用 Oracle NoSQL、Eclipse JNoSQL 和 Jakarta EE 連接這兩個世界——從定義您的領域到運行由 Cucumber 和 CDI 支持的真實行為測試。這種協同作用將測試轉化為活文檔,彌合了工程師和利益相關者之間的差距,並確保您的系統在演進過程中始終與業務目標保持一致。

您可以深入探索並將 DDD 與 BDD 結合起來。在《Domain-Driven Design with Java》這本書中,您可以找到一個很好的起點來理解為什麼 DDD 對我們仍然很重要。它擴展了這裏分享的想法,展示了 DDD 和 BDD 如何共同帶來更簡單、更易維護且以業務為中心的軟件。這種軟件交付的是超越需求的實際價值。