Ứng dụng Contract Testing khi tích hợp Microservices

Việc kiểm thử đã thay đổi như thế nào trong thế giới microservices? Ngoài các phương pháp kiểm thử cổ điển, trong bài viết này chúng ta sẽ tìm hiểu về Contract Testing và công cụ Pact giúp hỗ trợ hiện thực phương pháp này.

Upload image

Để dễ hiểu hơn chúng ta sẽ dùng ví dụ sau xuyên suốt bài viết:

Upload image

Chúng ta có hai mircoservices, giao tiếp thông qua REST. Provider service cung cấp thông tin liên quan đến user như name, email thông qua id của user. Consumer service lấy dữ liệu từ Provider và xử lý.

Các phương pháp kiểm thử cổ điển

Giả sử một ngày đẹp trời, một thành viên đội phát triển của bên Provider quyết định thay đổi trường email thành emails và không thông báo cho bên Consumer. Gây cấn hơn, nếu trong một hệ thống có hàng trăm hàng ngàn microservices thì ta còn không thể nhớ nỗi những service nào đang consume service của chúng ta. Nếu thay đổi này được deploy lên production thì chắc chắn sẽ xảy ra lỗi ở Consumer.

Liệu các phương pháp kiểm thử cổ điển có giúp chúng ta ngăn ngừa được tình huống huống này? Hãy cùng đi qua từng phương pháp của Agile Test Pyramid.

Upload image

Unit Testing

Phương pháp này nhanh, đáng tin cậy và rất phù hợp để kiểm thử logic trong từng service. Tuy nhiên, vì unit test được viết độc lập ở từng service nên cũng không thể phát hiện được việc tên các trường ở hai service đang khác nhau.

Integration Testing

Phương pháp này yêu cầu hai service phải thật sự giao tiếp với nhau nên chắc chắn sẽ phát hiện ra lỗi. Tuy nhiên, việc thực hiện phương pháp này rất chậm và tốn kém. Hai đội phát triển sẽ phải dành ra rất nhiều thời gian họp bàn về cách dựng môi trường, cách test, nếu có thay đổi thì sẽ như thế nào… Kể cả khi thống nhất được tất cả mọi thứ thì việc dựng môi trường cũng sẽ lại mất nhiều thời gian, khi có lỗi xảy ra thì cũng chưa chắc vấn đề là do code, có thể là do sai biến môi trường, do deploy sai version… Và khi lỗi do code thật sự xảy ra thì cũng phải mất thời gian vào xem logs của cả môi trường integration để biết được vấn đề xảy ra ở đâu.

End-to-end Testing

Phương pháp này giống như Integration Testing, hai service phải giao tiếp với nhau, việc phát hiện ra lỗi sẽ là 100%. Nhưng thay vì chỉ deploy hai service để test thì ta phải deploy toàn bộ hệ thống, tức là còn chậm và tốn kém hơn cả Integration Testing.

Contract Testing

Chính vì sự phức tạp của việc tích hợp các service với nhau, một phương pháp mới đã được ra đời, đó là Contract Testing. Đây là phương pháp đảm bảo hai service giao tiếp với nhau bằng cách kiểm tra độc lập sự tương thích của từng service đối với message mà chúng trao đổi.

Consumer sẽ ghi lại sự giao tiếp với Provider và tạo ra Contract. Contract là tài liệu đặc tả kỳ vọng request từ Consumer sẽ nhận được response tương ứng nào từ Provider.

Trong giai đoạn Unit Testing, code của ứng dụng sẽ tự động tạo ra Contract, giúp Contract luôn phản ảnh đúng với thực tế mới nhất. Sau khi Cosumer tạo ra Contract, Provider sẽ kiểm tra mình có đang làm đúng với Contract cũng trong giai đoạn Unit Testing. Trong trường hợp ít nhất một trong hai bên không đúng với Contract thì các bên sẽ phải bàn bạc để sửa lại.

Upload image

Chú ý là trong giai đoạn Unit Testing của cả hai bên, đội phát triển của Consumer và Provider hoạt động hoàn toàn độc lập và bất đồng bộ với nhau, họ chỉ sử dụng chung Contract.

Vì Contract Testing diễn ra trong giai đoạn Unit Testing và các bên có thể hoạt động độc lập sẽ tiết kiệm được thời gian và chi phí.

Pact

Pact là một framework mã nguồn mở để hiện thực Contract Testing. Sau đây sẽ là từng bước hiện thực ví dụ của chúng ta cùng với Pact.

Đầu tiên chúng ta sẽ hiện thực Provider và Consumer.

// pkg/provider/provider.go
package provider

import (
   "fmt"
   "github.com/gin-gonic/gin"
   "net/http"
)

type User struct {
   ID    string `json:"id"`
   Name  string `json:"name"`
   Email string `json:"email"`
}

func GetUserByID(ctx *gin.Context) {
   id := ctx.Param("userId")

   ctx.JSON(http.StatusOK, User{
      ID:    id,
      Name:  fmt.Sprintf("name%s", id),
      Email: fmt.Sprintf("email%s@gmail.com", id),
   })
}


// cmd/provider/provider.go
package main

import (
   "github.com/gin-gonic/gin"
   "pact-demo/pkg/provider"
)

func main() {
   router := gin.Default()
   router.GET("/users/:userId", provider.GetUserByID)
   router.Run(":8080")
}
// pkg/consumer/consumer,go
package consumer

import (
   "encoding/json"
   "fmt"
   "net/http"
   "pact-demo/pkg/provider"
)

func GetUserByID(host string, id string) (*provider.User, error) {
   uri := fmt.Sprintf("http://%s/users/%s", host, id)
   resp, err := http.Get(uri)
   if err != nil {
      return nil, err
   }
   defer resp.Body.Close()

   var user provider.User
   err = json.NewDecoder(resp.Body).Decode(&user)
   if err != nil {
      return nil, err
   }

   return &user, nil
}


// cmd/consumer/consumer.go
package main

import (
   "fmt"
   "pact-demo/pkg/consumer"
)

func main() {
   user, err := consumer.GetUserByID("localhost:8080", "1")
   if err != nil {
      panic(err)
   }

   fmt.Println(user)
}

Tiếp theo, chúng ta sẽ viết pact tests cho Consumer.

// pkg/consumer/consumer_test.go
package consumer

import (
   "errors"
   "fmt"
   "github.com/pact-foundation/pact-go/dsl"
   "pact-demo/pkg/provider"
   "testing"
)

func TestConsumer(t *testing.T) {
   // init Pack DSL
   pact := dsl.Pact{
      Consumer: "example-consumer",
      Provider: "example-provider",
   }

   // setup Pack mock provider
   pact.Setup(true)

   t.Run("get user by id", func(t *testing.T) {
      id := "1"

      pact.
         AddInteraction().
         Given("User John exists").                 // specify Provider state
         UponReceiving("User 'John' is requested"). // specify test case name
         WithRequest(dsl.Request{                   // specify expected request
            Method: "GET",
            Path:   dsl.Term("/users/1", "/users/[0-9]+"), // specify matching for endpoint
         }).
         WillRespondWith(dsl.Response{ // specify expected response
            Status: 200,
            Body: dsl.Like(provider.User{ // pecify matching for response body
               ID:    id,
               Name:  "John",
               Email: "john@gmail.com",
            }),
         })

      // verify contract
      err := pact.Verify(func() error {
         // use pact mock server
         host := fmt.Sprintf("%s:%d", pact.Host, pact.Server.Port)

         // send request
         user, err := GetUserByID(host, id)
         if err != nil {
            return errors.New("error is not expected")
         }

         // check if actual response match expected
         if user == nil || user.ID != id {
            return fmt.Errorf("expected user with ID %s but got %v", id, user)
         }

         return err
      })

      if err != nil {
         t.Fatal(err)
      }
   })

   // Write Contract file
   if err := pact.WritePact(); err != nil {
      t.Fatal(err)
   }

   pact.Teardown()
}

Trong đoạn code trên, chúng ta dùng Pact để tạo mock Provider. Sau đó khai báo các tương tác (interaction): khi có request như thế này đến endpoint này thì kỳ vọng là response phải có chứa ít nhất là những field này với giá trị như thế này.

Sau khi khai báo các tương tác, Pact sẽ chạy Consumer, lúc này Consumer sẽ gửi request tới mock Provider của pack. Nếu request khớp với các tương tác đã được khai báo thì sẽ có response tương ứng trả về và kết quả sẽ là Pass.

Cuối cùng, Pact sẽ ghi lại các tương tác vừa diễn ra vào file Contract.

Upload image

Chạy Pact tests và chúng ta có kết quả:

Upload image

Và nội dung của file Contract:

Upload image

Tiếp theo là pact tests cho Provider:

package provider

import (
   "github.com/pact-foundation/pact-go/dsl"
   "github.com/pact-foundation/pact-go/types"
   "testing"
)

func TestProvider(t *testing.T) {
   // init Pact DSl
   pact := dsl.Pact{
      Provider: "example-provider",
   }

   // verify contract
   _, err := pact.VerifyProvider(t, types.VerifyRequest{
      ProviderBaseURL: "http://127.0.0.1:8080",
      PactURLs:        []string{"../consumer/pacts/example-consumer-example-provider.json"},
   })

   if err != nil {
      t.Log(err)
   }
}

Lần này, chúng ta sẽ dùng Pact để mock Consumer. Mock Consumer sẽ gửi request đã được ghi lại trong file Contract tới Provider thật, nếu response trả về khớp với response trong file Contract thì sẽ là Pass.

Upload image

Chạy Pact tests và chúng ta có kết quả:

Upload image

Bây giờ theo như ví dụ, chúng ta sẽ đổi trường email ở Provider thành emails, chạy lại Pact tests của Provider, chúng ta có kết quả:

Upload image

Vậy là chúng ta đã có thể ngăn chặn được lỗi xảy ra một cách dễ dàng khi sử dụng Contract Testing. Có thể thấy, Contract Testing là phương pháp nhanh chóng và tiết kiệm chi phí để đảm bảo tính tương thích giữa các service. Contract Testing nói chung và Pact nói riêng vẫn còn những khía cạnh khác mà chúng ta sẽ cùng tìm hiểu trong bài viết tiếp theo.

Atekco - Home for Authentic Technical Consultants