บทที่ 9 · Part 2 — Building

Testing

Unit test, mock activity, time-skipping, ทดสอบ failure path และ Replay Test กัน non-determinism

Workflow ที่แตะเงินต้องทดสอบได้ก่อนขึ้น production — ไม่ใช่แค่ happy path แต่รวมถึงเคสที่ธนาคาร timeout, OTP หมดอายุ, compensation ต้องทำงาน ข่าวดีคือ Temporal ให้ test environment ที่ ข้ามเวลาได้ ทดสอบ timeout 5 นาที โดยใช้เวลาจริงไม่กี่มิลลิวินาที

จบบทนี้คุณจะ

  • เขียน unit test ของ workflow โดยไม่ต้องมี server
  • mock activity เพื่อทดสอบตรรกะการตัดสินใจล้วนๆ
  • ทดสอบ timeout เป็นวันด้วย time-skipping
  • พิสูจน์ได้ว่า compensation ทำงานจริงตอนล้ม
  • ตั้ง Replay Test เป็นด่านกัน non-determinism ใน CI

ภาพรวมเครื่องมือ

เครื่องมือทดสอบอะไรต้องมี server ไหม
TestWorkflowEnvironmentตรรกะของ workflow (mock activity ได้)ไม่ต้อง
TestActivityEnvironmentactivity ตัวเดียวโดดๆไม่ต้อง
WorkflowReplayerโค้ดใหม่ replay history เก่าได้ไหมไม่ต้อง (ใช้ไฟล์ history)
temporal server start-devintegration ของจริงทั้งระบบต้องมี

บทนี้เน้น 3 ตัวแรก เพราะรันใน CI ได้โดยไม่ต้องยก infrastructure

Test แรก

Go SDK ใช้ testsuite คู่กับ testify เขียนได้ 2 สไตล์ — แบบ suite เหมาะกับเทสหลายเคส ที่ใช้ setup ร่วมกัน ซึ่งเป็นกรณีปกติของ workflow

package workflows_test

import (
	"testing"

	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/suite"
	"go.temporal.io/sdk/testsuite"
)

type TopUpTestSuite struct {
	suite.Suite
	testsuite.WorkflowTestSuite

	env *testsuite.TestWorkflowEnvironment
}

func (s *TopUpTestSuite) SetupTest() {
	s.env = s.NewTestWorkflowEnvironment()
}

// ตรวจว่า mock ทุกตัวที่ตั้งไว้ถูกเรียกจริง — กัน test ที่ผ่านเพราะไม่ได้ทำอะไรเลย
func (s *TopUpTestSuite) AfterTest(suiteName, testName string) {
	s.env.AssertExpectations(s.T())
}

func TestTopUpTestSuite(t *testing.T) {
	suite.Run(t, new(TopUpTestSuite))
}
func (s *TopUpTestSuite) Test_TopUp_Success() {
	var a *activities.Activities

	s.env.OnActivity(a.ValidateWallet, mock.Anything, mock.Anything).Return(nil)
	s.env.OnActivity(a.ChargeBank, mock.Anything, mock.Anything).Return("BANKREF-001", nil)
	s.env.OnActivity(a.CreditWallet, mock.Anything, mock.Anything).Return(nil)

	s.env.ExecuteWorkflow(workflows.TopUpWorkflow, TopUpRequest{
		TransactionID: "TXN-001",
		WalletID:      "W-001",
		AmountSatang:  100_00,
	})

	s.True(s.env.IsWorkflowCompleted())
	s.NoError(s.env.GetWorkflowError())

	var result TopUpResult
	s.NoError(s.env.GetWorkflowResult(&result))
	s.Equal("BANKREF-001", result.BankRef)
}

`var a *activities.Activities` ที่เป็น nil ใช้ได้

เมื่อ activity เป็น method ของ struct เราอ้างถึงมันด้วย method value อย่าง a.ChargeBank SDK อ่านแค่ชื่อ ของ method ผ่าน reflection ไม่ได้เรียกจริง pointer เป็น nil จึงไม่มีปัญหา และไม่ต้องสร้าง struct จริงในเทส

ExecuteWorkflow เป็น blocking — พอ return แล้ว workflow จบไปเรียบร้อย จึงเช็ค IsWorkflowCompleted() ได้ทันที

Mock activity

OnActivity มี 3 รูปแบบ เลือกตามสิ่งที่อยากพิสูจน์

// 1. คืนค่าตายตัว — สนใจแค่ผลลัพธ์
s.env.OnActivity(a.ChargeBank, mock.Anything, mock.Anything).Return("BANKREF-001", nil)

// 2. คืนค่าผ่านฟังก์ชัน — ตรวจ argument ที่ workflow ส่งมาได้
s.env.OnActivity(a.ChargeBank, mock.Anything, mock.Anything).Return(
	func(ctx context.Context, req TopUpRequest) (string, error) {
		// พิสูจน์ว่า idempotency key ถูกส่งไปจริง และเป็นค่าที่คาดไว้
		s.Equal("TXN-001", req.TransactionID)
		s.Equal(int64(100_00), req.AmountSatang)
		return "BANKREF-001", nil
	})

// 3. จับ argument เจาะจง — ให้ mock ตอบต่างกันตามอินพุต
s.env.OnActivity(a.ValidateWallet, mock.Anything, "W-BLOCKED").
	Return(temporal.NewNonRetryableApplicationError("wallet frozen", "WalletFrozen", nil))

ลายเซ็นของ mock ต้องตรงกับ activity จริงเป๊ะ

ถ้าไม่ตรง เทสจะพังด้วยข้อความที่อ่านยาก (mock ไม่ match แล้วไปเรียกตัวจริงแทน) เวลาแก้ signature ของ activity ต้องไล่แก้ mock ทุกที่ด้วย — นี่คือเหตุผลที่ควรมีเทสครอบคลุม ไม่งั้นจะไม่รู้ว่าลืมตรงไหน

Activity ที่ไม่ได้ mock ต้อง register ตัวจริง ไม่งั้น SDK หาไม่เจอ:

s.env.RegisterActivity(&activities.Activities{DB: testDB})

Time-skipping — ทดสอบ timeout โดยไม่ต้องรอ

นี่คือความสามารถที่ทำให้ test ของ Temporal ต่างจาก framework อื่น TestWorkflowEnvironment มีนาฬิกาจำลอง — พอ workflow เรียก workflow.Sleep(24*time.Hour) environment จะกระโดดเวลาไปข้างหน้าทันที ไม่มีการรอจริง

// จากบทที่ 8: รอ T+1 แล้วเช็ค settlement — เทสจบใน ~1 มิลลิวินาที
func (s *TopUpTestSuite) Test_SettlementCheck_NotSettled_RaisesAlert() {
	var a *activities.Activities

	s.env.OnActivity(a.CheckSettlement, mock.Anything, mock.Anything).Return(false, nil)
	s.env.OnActivity(a.RaiseOpsAlert, mock.Anything, mock.Anything, mock.Anything).
		Return(nil).Once()

	s.env.ExecuteWorkflow(workflows.SettlementCheckWorkflow, BankTransferRef{
		TransactionID: "TXN-001",
	})

	s.True(s.env.IsWorkflowCompleted())
	s.NoError(s.env.GetWorkflowError())
}

ส่ง signal ระหว่างทางด้วย RegisterDelayedCallback — เวลาที่ระบุคือเวลาจำลอง นับจากตอนเริ่ม workflow

// OTP มาทันใน 2 นาที (timeout ตั้งไว้ 5 นาที)
func (s *TopUpTestSuite) Test_OTP_SubmittedInTime() {
	var a *activities.Activities
	s.env.OnActivity(a.SendOTP, mock.Anything, mock.Anything).Return(nil)
	s.env.OnActivity(a.VerifyOTP, mock.Anything, mock.Anything, mock.Anything).Return(true, nil)

	s.env.RegisterDelayedCallback(func() {
		s.env.SignalWorkflow("otp-submitted", OTPSubmission{Code: "123456"})
	}, 2*time.Minute)

	s.env.ExecuteWorkflow(workflows.TopUpWithOTPWorkflow, TopUpRequest{WalletID: "W-001"})

	s.True(s.env.IsWorkflowCompleted())
	s.NoError(s.env.GetWorkflowError())
}

เคสตรงข้าม — ไม่ส่ง signal เลย ปล่อยให้ timer ยิง:

func (s *TopUpTestSuite) Test_OTP_TimesOut() {
	var a *activities.Activities
	s.env.OnActivity(a.SendOTP, mock.Anything, mock.Anything).Return(nil)
	// ไม่ mock VerifyOTP เพราะไม่ควรถูกเรียกเลย

	s.env.ExecuteWorkflow(workflows.TopUpWithOTPWorkflow, TopUpRequest{WalletID: "W-001"})

	s.True(s.env.IsWorkflowCompleted())

	err := s.env.GetWorkflowError()
	s.Error(err)

	var appErr *temporal.ApplicationError
	s.True(errors.As(err, &appErr))
	s.Equal("OTPTimeout", appErr.Type())
}

Time-skipping ข้ามเฉพาะ timer ของ Temporal

workflow.Sleep, workflow.NewTimer, AwaitWithTimeout และ activity timeout ถูกข้ามให้ แต่ time.Sleep ใน activity ของเราเองจะรอจริง ถ้าเทสช้าผิดปกติ ให้สงสัยว่ามี sleep จริงหลุดอยู่ใน activity ที่ไม่ได้ mock

ทดสอบว่า compensation ทำงานจริง

เทสที่สำคัญที่สุดของระบบการเงินคือ "ตอนล้มแล้วเงินกลับที่เดิมไหม" Saga จากบทที่ 6 พิสูจน์ได้ด้วยการ mock ให้ขั้นตอนกลางล้ม แล้ว assert ว่า compensation ถูกเรียก

func (s *WithdrawTestSuite) Test_BankTransferFails_RefundsWallet() {
	var a *activities.Activities

	s.env.OnActivity(a.DebitWallet, mock.Anything, mock.Anything).Return(nil).Once()
	s.env.OnActivity(a.TransferToBank, mock.Anything, mock.Anything).Return("",
		temporal.NewNonRetryableApplicationError("account closed", "AccountClosed", nil)).Once()

	// นี่คือสิ่งที่ต้องพิสูจน์: เงินถูกคืนเข้า wallet 1 ครั้งพอดี
	s.env.OnActivity(a.RefundWallet, mock.Anything, mock.Anything).Return(nil).Once()

	s.env.ExecuteWorkflow(workflows.WithdrawWorkflow, WithdrawRequest{
		TransactionID: "TXN-002",
		WalletID:      "W-001",
		AmountSatang:  500_00,
	})

	s.True(s.env.IsWorkflowCompleted())
	s.Error(s.env.GetWorkflowError())
	// AfterTest เรียก AssertExpectations ให้แล้ว — ถ้า RefundWallet ไม่ถูกเรียก เทสจะ fail
}

.Once() สำคัญกว่าที่คิด: มันจับทั้งกรณี "ไม่ได้คืนเงิน" และ "คืนเงิน 2 รอบ" ซึ่งอย่างหลังเป็นบั๊กที่แพงกว่า

ยืนยันเชิงลบด้วย AssertNotCalled เมื่ออยากพิสูจน์ว่า ไม่ มีการทำอะไร:

// ผ่าน point of no return แล้ว ห้าม compensate เด็ดขาด
s.env.AssertNotCalled(s.T(), "RefundWallet", mock.Anything, mock.Anything)

ทดสอบ Query และ Update

Query อ่านได้ระหว่างที่ workflow ยังทำงาน โดยยิงจากใน RegisterDelayedCallback

func (s *TopUpTestSuite) Test_Query_StageProgresses() {
	var a *activities.Activities
	s.env.OnActivity(a.SendOTP, mock.Anything, mock.Anything).Return(nil)

	s.env.RegisterDelayedCallback(func() {
		res, err := s.env.QueryWorkflow("status")
		s.NoError(err)

		var status TopUpStatus
		s.NoError(res.Get(&status))
		s.Equal("awaiting_otp", status.Stage)
	}, time.Minute)

	s.env.ExecuteWorkflow(workflows.TopUpWithOTPWorkflow, TopUpRequest{WalletID: "W-001"})
	s.True(s.env.IsWorkflowCompleted())
}

Update ทดสอบด้วย UpdateWorkflow พร้อม callback รับผลลัพธ์ — ตรวจได้ทั้งเคสที่ validator ผ่านและถูกปฏิเสธ

s.env.RegisterDelayedCallback(func() {
	s.env.UpdateWorkflow("approve-override", "update-1", &testsuite.TestUpdateCallback{
		OnAccept: func() {},
		OnReject: func(err error) {
			s.Fail("validator should have accepted", err)
		},
		OnComplete: func(result interface{}, err error) {
			s.NoError(err)
		},
	}, OverrideRequest{NewLimitSatang: 2_000_00, ApproverID: "ops-42"})
}, time.Minute)

ทดสอบ Activity แยกเดี่ยว

Activity เป็นโค้ด Go ธรรมดา จะเทสตรงๆ ก็ได้ แต่ถ้าอยากให้ activity.GetInfo(ctx) กับ heartbeat ทำงานได้ ต้องใช้ TestActivityEnvironment

func Test_ChargeBank_SendsIdempotencyKey(t *testing.T) {
	var captured string
	fakeBank := &fakeBankClient{
		onCharge: func(key string, amountSatang int64) (string, error) {
			captured = key
			return "BANKREF-001", nil
		},
	}

	suite := &testsuite.WorkflowTestSuite{}
	env := suite.NewTestActivityEnvironment()
	env.RegisterActivity(&activities.Activities{Bank: fakeBank})

	val, err := env.ExecuteActivity((&activities.Activities{}).ChargeBank, TopUpRequest{
		TransactionID: "TXN-001",
		AmountSatang:  100_00,
	})
	require.NoError(t, err)

	var bankRef string
	require.NoError(t, val.Get(&bankRef))
	require.Equal(t, "BANKREF-001", bankRef)
	require.Equal(t, "TXN-001", captured) // idempotency key = transaction ID
}

Replay Test — ด่านกัน non-determinism

Test ทั้งหมดข้างบนรัน workflow ตั้งแต่ต้น จึงจับ non-determinism ไม่ได้เลย เพราะปัญหานั้นเกิดตอนโค้ดใหม่ replay history ที่โค้ดเก่าสร้างไว้

Replay Test แก้จุดนี้: เอา history จริงจาก production มา replay ผ่านโค้ดปัจจุบัน ถ้า command ที่ได้ไม่ตรงกับ event ที่บันทึกไว้ เทสจะ fail

# ดึง history ของ workflow ที่รันจริงมาเก็บไว้เป็นไฟล์
temporal workflow show \
  --workflow-id topup-TXN-001 \
  --output json > testdata/topup_v1.json
func Test_Replay_TopUpWorkflow(t *testing.T) {
	entries, err := os.ReadDir("testdata")
	require.NoError(t, err)

	for _, entry := range entries {
		if !strings.HasSuffix(entry.Name(), ".json") {
			continue
		}

		t.Run(entry.Name(), func(t *testing.T) {
			replayer := worker.NewWorkflowReplayer()
			replayer.RegisterWorkflow(workflows.TopUpWorkflow)
			replayer.RegisterWorkflow(workflows.WithdrawWorkflow)

			err := replayer.ReplayWorkflowHistoryFromJSONFile(nil,
				filepath.Join("testdata", entry.Name()))
			require.NoError(t, err, "โค้ดปัจจุบัน replay history นี้ไม่ผ่าน")
		})
	}
}

เก็บ history เป็น fixture ไว้ใน repo

ทุกครั้งที่ปล่อย workflow เวอร์ชันใหม่ ให้ export history ของรันจริง 1 เคสต่อ 1 path สำคัญ (สำเร็จ, ล้มแล้ว compensate, timeout, ถูก cancel) เก็บลง testdata/ ชุดไฟล์นี้จะกลายเป็น regression suite ที่จับ non-determinism ได้ตั้งแต่ใน CI ก่อนโค้ดจะไปเจอธุรกรรมจริง

ทำให้เป็นด่านบังคับใน CI:

- name: Replay tests
  run: go test ./workflows/... -run Test_Replay -v

Replay Test ไม่ได้ครอบคลุมทุกอย่าง

มันพิสูจน์ได้แค่ว่า history ที่มีในไฟล์ replay ผ่าน ถ้า production มี execution path ที่ไม่มีใน fixture การแก้โค้ดก็ยังพังได้อยู่ดี จึงต้องใช้คู่กับ versioning ที่ถูกต้อง — เรื่องของบทที่ 10

Integration test กับ dev server

เมื่ออยากทดสอบทั้งระบบจริง (worker + server + DB) ใช้ DevServer ของ testsuite

func TestIntegration_TopUp(t *testing.T) {
	if testing.Short() {
		t.Skip("integration test")
	}

	srv, err := testsuite.StartDevServer(context.Background(), testsuite.DevServerOptions{})
	require.NoError(t, err)
	defer func() { _ = srv.Stop() }()

	c := srv.Client()

	// task queue ไม่ซ้ำกันต่อเทส กัน worker ของเทสอื่นมาแย่งงาน
	taskQueue := "test-" + t.Name()
	w := worker.New(c, taskQueue, worker.Options{})
	w.RegisterWorkflow(workflows.TopUpWorkflow)
	w.RegisterActivity(&activities.Activities{Bank: fakeBank})
	require.NoError(t, w.Start())
	defer w.Stop()

	run, err := c.ExecuteWorkflow(context.Background(), client.StartWorkflowOptions{
		ID:        "topup-TXN-001",
		TaskQueue: taskQueue,
	}, workflows.TopUpWorkflow, TopUpRequest{TransactionID: "TXN-001", AmountSatang: 100_00})
	require.NoError(t, err)

	var result TopUpResult
	require.NoError(t, run.Get(context.Background(), &result))
	require.Equal(t, "BANKREF-001", result.BankRef)
}

Integration test ไม่มี time-skipping

StartDevServer เป็น server จริง timer จึงเดินตามเวลาจริง อย่าเอา workflow ที่มี Sleep(24*time.Hour) มาเทสแบบนี้ แบ่งหน้าที่ให้ชัด: ตรรกะและ timeout → unit test, การต่อสายทั้งระบบ → integration test

Checklist ชุดเทสของ workflow ที่แตะเงิน

  • Happy path ได้ผลลัพธ์และ side effect ตามคาด
  • ทุก failure path ที่เขียน compensation ไว้ มีเทสพิสูจน์ว่า compensate 1 ครั้ง
  • เคสหลัง point of no return มีเทสยืนยันว่า ไม่ compensate
  • Timeout ทุกตัวมีเทส (ใช้ time-skipping ไม่ใช่ time.Sleep)
  • Non-retryable error ไม่ถูก retry — เช็คด้วย .Once() บน mock
  • Idempotency key ที่ส่งเข้า activity ถูกตรวจด้วย mock แบบฟังก์ชัน
  • มี replay fixture อย่างน้อย 1 ไฟล์ต่อ 1 execution path สำคัญ
  • Replay test เป็นด่านบังคับใน CI ไม่ใช่เทสที่ข้ามได้