在軟件開發領域,最大的錯誤之一就是交付客户"精確"想要的東西。這聽起來可能像陳詞濫調,但即使在行業摸爬滾打數十年後,這個問題依然存在。一個更有效的方法是從關注業務需求開始測試。
行為驅動開發【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 JNoSQL 與 Oracle 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 如何共同帶來更簡單、更易維護且以業務為中心的軟件。這種軟件交付的是超越需求的實際價值。