Ở bài trước, chúng ta đã nhìn vào testing từ góc độ của một software component: làm thế nào để kiểm tra logic bên trong component một cách nhanh, độc lập và có thể kiểm soát được. Đó là vùng đất của Unit Testing, nơi các dependency thường được cô lập để chúng ta tập trung vào behavior của chính component đó.
Nhưng một component hiếm khi tồn tại một mình. Trong một hệ thống thực tế, nó phải làm việc với database, API, message broker, filesystem hoặc những service khác. Và việc tất cả unit test đều pass vẫn chưa đảm bảo rằng những phần này thực sự hiểu và làm việc đúng với nhau. Đó là vấn đề mà Integration Testing giải quyết.
Nếu Unit Test tập trung vào câu hỏi “logic bên trong component có đúng không?”, thì Integration Test bắt đầu hỏi một câu khác: “component này có thực sự hoạt động đúng khi được kết nối với những dependency mà nó cần không?” Từ đây, phạm vi của testing bắt đầu mở rộng: từ một component riêng lẻ sang cách component đó thực sự tương tác với thế giới bên ngoài.
Integration Testing không phải là Unit Testing phiên bản lớn hơn
Một cách hiểu khá phổ biến là Integration Test đơn giản chỉ là Unit Test nhưng chạy nhiều thứ cùng lúc. Cách nhìn này chưa thực sự chính xác. Unit Test thường cố gắng cô lập component khỏi dependency của nó — chúng ta có thể mock database, external API hoặc message broker để kiểm tra logic của component một cách độc lập. Integration Test thì ngược lại: nó muốn kiểm tra component khi làm việc với actual dependencies.
Ví dụ, một service có API để tạo chart và sử dụng PostgreSQL. Ở Unit Test, chúng ta có thể mock PostgreSQL để kiểm tra validation, business rules và data transformation. Những test này giúp chúng ta biết logic bên trong service có đúng hay không, nhưng chúng chưa trả lời được những câu hỏi như:
- service có thực sự kết nối được với PostgreSQL không,
- query có đúng với database schema không,
- data có thực sự được ghi xuống database không,
- response trả về có đúng contract không.
Đây chính là phần mà Integration Test cần kiểm tra.
Vì vậy, mục tiêu của Integration Testing không phải là test lại toàn bộ logic đã được kiểm tra bằng Unit Test. Thay vào đó, mỗi public function của component nên được chạm tới bởi ít nhất một integration test, nhưng không cần kiểm tra lại tất cả các nhánh logic bên trong nó.
Đó cũng là lý do một testing strategy hợp lý thường có rất nhiều Unit Test, ít Integration Test hơn và thậm chí còn ít End-to-End Test hơn nữa. Mỗi tầng đang trả lời một câu hỏi khác nhau.
Integration Test nên nhìn component như một Black Box
Một trong những cách tốt để xây dựng Integration Test là sử dụng Black-box Testing. Test không cần biết bên trong component được implement như thế nào, nó chỉ quan tâm đến input và output:
Input → Component → Output
Ví dụ, nếu chúng ta có một REST API, integration test chỉ cần biết request nào được gửi vào và response nào được mong đợi. Nó không cần biết bên trong request đó đi qua bao nhiêu class, function hay module.
Điều này tạo ra một property rất quan trọng: integration test ít phụ thuộc vào implementation. Hôm nay service có thể được viết bằng TypeScript, ngày mai team có thể rewrite nó bằng Go hoặc Java. Nếu public API và behavior vẫn giữ nguyên, integration test về cơ bản không cần thay đổi. Đây là điểm khác biệt rất đáng giá so với những test phụ thuộc vào implementation detail.
Integration test cũng có thể được viết bằng một programming language hoặc testing framework khác với application, bởi vì test không quan tâm source code bên trong — nó chỉ giao tiếp với component thông qua public interface. Và khi test thực sự được thiết kế như một black box, chúng ta thậm chí không cần đợi implementation hoàn thành mới bắt đầu viết test.
Bắt đầu từ behavior thay vì implementation
Đây là lúc BDD và ATDD trở nên hữu ích. Thay vì bắt đầu một feature bằng câu hỏi “chúng ta cần viết function nào?”, team có thể bắt đầu bằng một câu hỏi khác: “feature này nên behave như thế nào?”
Ví dụ, thay vì nói rằng chúng ta cần implement một function createChart(), chúng ta có thể định nghĩa behavior:
Khi người dùng cung cấp thông tin chart hợp lệ và gửi request tạo chart, hệ thống phải tạo chart mới và trả về HTTP 201.
Behavior này có thể trở thành specification. Sau đó specification trở thành integration test, và đồng thời có thể đóng vai trò như acceptance test cho feature. Đây là một trong những ý tưởng quan trọng của BDD và ATDD: behavior specification trở thành nguồn gốc của test.
Cách tiếp cận này cũng kéo việc testing lên sớm hơn trong development lifecycle: specification được viết trước khi bắt đầu implementation, thay vì đợi implementation xong mới nghĩ tới test. Đây là thứ thường được gọi là shift-left testing — “trái” ở đây là sớm hơn trên trục thời gian của dự án. Integration Test không còn là thứ chỉ được viết sau khi developer hoàn thành implementation; team có thể xác định behavior và viết specification ngay khi feature được thống nhất, sau đó implementation và test có thể được phát triển song song.
Điều này còn tạo ra một shared understanding giữa developer, tester, product và những stakeholder khác. Mọi người không chỉ thống nhất rằng “feature này đã được implement”, mà còn thống nhất được thế nào là behavior đúng của feature đó.
Không cần test mọi error và edge case ở Integration Test
Behavioral specification nên tập trung vào những scenario có ý nghĩa ở cấp độ integration. Thông thường, chúng ta nên bắt đầu với happy path — trường hợp hệ thống hoạt động theo cách bình thường và hợp lệ — sau đó bổ sung những failure scenario và edge case quan trọng.
Ví dụ, với API tạo chart, chúng ta có thể có một scenario cho việc tạo chart thành công và một scenario cho việc request thiếu các mandatory parameter. Nhưng chúng ta không nhất thiết phải đưa tất cả những trường hợp input sai có thể tưởng tượng được vào integration test. Những validation rule nhỏ, boundary value hoặc các combination input phức tạp nên được kiểm tra chủ yếu bằng Unit Test.
Integration Test nên tập trung vào việc xác nhận rằng component và dependency của nó thực sự phối hợp đúng với nhau. Nếu cố gắng dùng Integration Test để kiểm tra mọi thứ, test suite sẽ nhanh chóng trở nên lớn, chậm và khó maintain.
Gherkin chỉ là một cách để mô tả behavior
Một format phổ biến để viết behavioral specification là Gherkin với cấu trúc quen thuộc Given → When → Then. Ví dụ:
Feature: Create chart
Scenario: Create a new chart successfully
Given chart layout id is 1
And chart type is "line"
And X-axis categories shown count is 10
And fetched row count is 1000
When I create a new chart
Then I should get the chart given above
And status code should be 201
Given mô tả context hoặc trạng thái ban đầu, When mô tả action xảy ra, còn Then mô tả kết quả mong đợi.
Điểm quan trọng ở đây là BDD không đồng nghĩa với Gherkin. Gherkin chỉ là một cách để biểu diễn behavioral specification. Chúng ta hoàn toàn có thể áp dụng tư duy BDD mà không sử dụng Gherkin, hoặc sử dụng một testing framework khác để implement integration test. Điều quan trọng không phải là team có .feature file hay không, mà là behavior của system được xác định rõ ràng và có thể được kiểm chứng một cách tự động.
Feature nên có thể chứng minh từ đầu đến cuối
Có một nguyên tắc khá thú vị khi thiết kế feature cho Integration Testing: một feature nên có khả năng được chứng minh bằng một end-to-end integration test.
Hãy tưởng tượng chúng ta đang xây dựng một data exporter microservice. Service này nhận Avro message từ Kafka và export sang Pulsar. Nếu feature đầu tiên chỉ là “consume Avro binary messages from Kafka” thì integration test sẽ gặp một vấn đề: chúng ta có thể kiểm tra service có đọc được message từ Kafka hay không, nhưng output thực sự của feature là gì? Chúng ta sẽ compare nó với cái gì?
Thay vào đó, một feature như “export a message from Kafka to Pulsar” có một flow hoàn chỉnh:
Kafka → Service → Pulsar
Bây giờ chúng ta có thể đưa một message vào Kafka, quan sát message xuất hiện ở Pulsar và verify kết quả.
Đây chính là ý tưởng của Walking Skeleton. Thay vì xây dựng rất nhiều phần bên trong trước rồi cuối cùng mới tìm cách test toàn bộ system, chúng ta trước tiên xây dựng một vertical slice nhỏ nhưng có thể chạy được từ đầu đến cuối. Sau đó, các capability khác được thêm dần vào xung quanh nó: filtering, type conversion, expression transformation, TLS, CSV export, JSON export.
Walking skeleton giống như bộ xương đầu tiên của system. Khi bộ xương đó đã có thể chạy từ input đến output, chúng ta mới tiếp tục thêm “flesh” vào xung quanh. Điều này tạo ra một lợi ích rất thực tế: ngay từ những feature đầu tiên, team đã có một system có thể chạy, test và demonstrate được.
Integration Test không nên phụ thuộc quá nhiều vào framework
Một vấn đề khác cần cân nhắc là việc sử dụng framework-specific testing tools. Nhiều framework cung cấp sẵn testing tool của riêng nó, và ban đầu điều này rất tiện vì developer đã quen với framework.
Nhưng Integration Test có một đặc điểm khác Unit Test: nó nên tồn tại độc lập với implementation càng nhiều càng tốt. Nếu một microservice Python sử dụng một framework-specific testing tool, sau này service được rewrite sang Go hoặc Java thì integration test cũng có thể phải viết lại. Nếu test được viết như một black-box test thông qua API hoặc public interface, nó có thể sống lâu hơn implementation hiện tại.
Đây cũng là lý do generic BDD tools như Behave có thể hữu ích trong môi trường microservices. Service A có thể viết bằng Python, service B bằng Java và service C bằng Node.js, nhưng nếu chúng đều expose một public API thì integration test có thể sử dụng cùng một cách tiếp cận. Điều này cũng làm giảm learning burden cho QA và test engineers: thay vì phải học một testing framework khác nhau cho từng microservice, team có thể sử dụng một tool và một cách viết test tương đối thống nhất.
Với API, Postman cũng có thể trở thành Integration Test
Không phải lúc nào chúng ta cũng cần một BDD framework. Đối với API microservices, Postman cũng có thể được sử dụng để xây dựng integration test. Ví dụ, chúng ta gửi request:
POST /sales-items
sau đó kiểm tra response có status code 201, tên và giá của sales item có đúng hay không. ID của object vừa tạo có thể được lưu lại và sử dụng trong request tiếp theo:
GET /sales-items/{{salesItemId}}
Như vậy, test không chỉ kiểm tra từng API riêng lẻ mà có thể kiểm tra cả một flow:
Create → Store ID → Retrieve → Verify
Postman collection sau đó có thể được export thành JSON và chạy trong CI bằng Newman.
Điều đáng chú ý ở đây không phải Postman hay Behave là công cụ nào “đúng” hơn — công cụ chỉ là phương tiện. Điều quan trọng là integration test phải kiểm tra behavior thông qua public interface và có thể chạy tự động, lặp lại được trong môi trường CI.
Integration Testing cũng áp dụng cho UI
Cùng một tư duy có thể áp dụng cho web UI. Ví dụ một feature đơn giản:
Feature: Greet user
Scenario: Greet user successfully
Given there is "John Doe" entered in the input field
When I press the submit button
Then I am greeted with text "Hello, John Doe"
Ở đây chúng ta không quan tâm button được implement bằng React hay Vue, state được quản lý bằng Redux hay một thư viện khác. Điều chúng ta quan tâm là behavior mà user có thể quan sát:
Input → Action → Result
Các tool như TestCafe hoặc Cypress có thể được sử dụng để implement những test này, và Gherkin có thể được sử dụng nếu team muốn behavioral specification được tách khỏi implementation.
Integration Test cần một environment thật
Một khi chúng ta quyết định test với actual dependencies, sẽ xuất hiện một vấn đề rất thực tế: những dependency đó phải tồn tại ở đâu? Ví dụ một sales-item-service sử dụng MySQL — integration test không thể chỉ start service rồi chạy test nếu database chưa tồn tại.
Một environment đơn giản có thể là Integration Test → Sales Item Service → MySQL. Nếu service sử dụng thêm Kafka thì environment có thể trở thành Integration Test → Sales Item Service → Kafka → Zookeeper, và service đồng thời vẫn sử dụng MySQL.
Với các application chạy bằng container, Docker Compose là một cách đơn giản để tạo integration testing environment. CI pipeline có thể thực hiện một flow như:
Start environment
↓
Wait for dependencies
↓
Run integration tests
↓
Collect results
↓
Shutdown environment
Một chi tiết rất dễ bị bỏ qua là container đang running không có nghĩa service đã ready. Database có thể đã start nhưng vẫn đang initialize. Application container có thể đang chạy nhưng HTTP server chưa sẵn sàng nhận request. Vì vậy, trước khi chạy integration test, chúng ta cần đảm bảo các dependency thực sự ready.
Sau khi test hoàn thành, environment có thể được shutdown và tạo lại từ đầu trong lần chạy tiếp theo. Điều này giúp test environment trở nên predictable hơn và giảm sự phụ thuộc vào state còn sót lại từ những lần chạy trước.
Integration Testing thực sự đang kiểm tra điều gì?
Sau cùng, có thể nhìn testing strategy như một quá trình mở rộng phạm vi. Unit Test bắt đầu từ bên trong một component:
Logic bên trong component có đúng không?
Integration Test mở rộng ra ngoài component:
Component có thực sự làm việc đúng với những dependency của nó không?
Và khi chúng ta tiếp tục mở rộng phạm vi ra toàn bộ system:
Toàn bộ system có thực sự thực hiện đúng business flow từ đầu đến cuối không?
Đó sẽ là câu hỏi của End-to-End Testing.
Vì vậy, Integration Test không phải là Unit Test phiên bản lớn hơn. Nó tồn tại để kiểm tra một loại rủi ro khác — rủi ro nằm ở boundary giữa các thành phần của system:
- Database có thực sự nhận đúng dữ liệu không?
- API có thực sự tuân thủ contract không?
- Message có thực sự đi qua broker đến destination không?
- Service có thực sự hiểu đúng interface của dependency không?
Những câu hỏi này rất khó được trả lời chỉ bằng mock, và đó là lý do Integration Testing tồn tại.
Và cũng giống như Unit Testing, mục tiêu không phải là tạo ra càng nhiều test càng tốt, mà là đặt đúng loại test ở đúng nơi. Unit Test giúp chúng ta tin rằng logic bên trong đúng. Integration Test giúp chúng ta tin rằng các component thực sự làm việc đúng với nhau.
Còn bước tiếp theo sẽ là mở rộng góc nhìn thêm một lần nữa: từ component và dependency sang toàn bộ system và user journey — End-to-End Testing.