Skip to content
Go
测试指南

测试指南

1 概述

Asynq 的 Handler 本质上是纯函数 func(ctx context.Context, t *Task) error,因此测试非常直观——不需要启动 Redis 或 Server,直接构造 Task 和 Context 调用 Handler 即可。

2 Handler 单元测试

2.1 基础测试模式

func TestHandleEmailTask(t *testing.T) {
    // 1. 构造 Payload
    payload, _ := json.Marshal(EmailPayload{
        UserID:     42,
        TemplateID: "welcome",
    })

    // 2. 创建 Task
    task := asynq.NewTask("email:send", payload)

    // 3. 准备 context
    ctx := context.Background()

    // 4. 调用 Handler
    err := HandleEmailTask(ctx, task)

    // 5. 断言
    if err != nil {
        t.Errorf("unexpected error: %v", err)
    }
}

2.2 带依赖注入的测试

func TestEmailHandler_ProcessTask(t *testing.T) {
    // 使用 mock
    mailer := &MockMailer{}
    handler := &EmailHandler{mailer: mailer}

    payload, _ := json.Marshal(EmailPayload{UserID: 42, TemplateID: "welcome"})
    task := asynq.NewTask("email:send", payload)

    err := handler.ProcessTask(context.Background(), task)

    if err != nil {
        t.Errorf("unexpected error: %v", err)
    }
    if !mailer.SendCalled {
        t.Error("expected mailer.Send to be called")
    }
}

type MockMailer struct {
    SendCalled bool
}

func (m *MockMailer) Send(ctx context.Context, to, subject, body string) error {
    m.SendCalled = true
    return nil
}

2.3 测试错误处理

func TestHandleEmailTask_InvalidPayload(t *testing.T) {
    // 构造无效 Payload
    task := asynq.NewTask("email:send", []byte("invalid json"))

    err := HandleEmailTask(context.Background(), task)

    if err == nil {
        t.Fatal("expected error, got nil")
    }
    if !errors.Is(err, asynq.SkipRetry) {
        t.Errorf("expected SkipRetry, got: %v", err)
    }
}

func TestHandleEmailTask_TemporaryError(t *testing.T) {
    payload, _ := json.Marshal(EmailPayload{UserID: 42, TemplateID: "welcome"})
    task := asynq.NewTask("email:send", payload)

    // 模拟临时错误
    handler := &EmailHandler{
        mailer: &FailingMailer{err: errors.New("connection refused")},
    }

    err := handler.ProcessTask(context.Background(), task)

    if err == nil {
        t.Fatal("expected error, got nil")
    }
    if errors.Is(err, asynq.SkipRetry) {
        t.Error("unexpected SkipRetry for temporary error")
    }
}

2.4 前后对比测试

func TestHandleEmailTask(t *testing.T) {
    tests := []struct {
        name    string
        payload interface{}
        wantErr bool
        wantSkipRetry bool
    }{
        {
            name: "valid payload",
            payload: EmailPayload{UserID: 42, TemplateID: "welcome"},
            wantErr: false,
        },
        {
            name: "invalid json",
            payload: "not-json",
            wantErr: true,
            wantSkipRetry: true,
        },
        {
            name: "missing user",
            payload: EmailPayload{UserID: 0, TemplateID: "welcome"},
            wantErr: true,
            wantSkipRetry: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            payload, _ := json.Marshal(tt.payload)
            task := asynq.NewTask("email:send", payload)

            err := HandleEmailTask(context.Background(), task)

            if (err != nil) != tt.wantErr {
                t.Errorf("error = %v, wantErr = %v", err, tt.wantErr)
            }
            if tt.wantSkipRetry && !errors.Is(err, asynq.SkipRetry) {
                t.Errorf("expected SkipRetry, got: %v", err)
            }
        })
    }
}

3 测试超时行为

func TestHandler_Timeout(t *testing.T) {
    payload, _ := json.Marshal(MyPayload{})
    task := asynq.NewTask("slow:task", payload)

    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()

    err := SlowHandler(ctx, task)

    if !errors.Is(err, context.DeadlineExceeded) {
        t.Errorf("expected DeadlineExceeded, got: %v", err)
    }
}

4 测试中间件

func TestLoggingMiddleware(t *testing.T) {
    var buf bytes.Buffer
    logger := log.New(&buf, "", 0)

    // 创建一个简单的中间件
    mid := func(next asynq.Handler) asynq.Handler {
        return asynq.HandlerFunc(func(ctx context.Context, t *asynq.Task) error {
            logger.Printf("before: %s", t.Type())
            err := next.ProcessTask(ctx, t)
            logger.Printf("after: %s err=%v", t.Type(), err)
            return err
        })
    }

    // 包装 Handler
    handler := mid(asynq.HandlerFunc(func(ctx context.Context, t *asynq.Task) error {
        return nil
    }))

    task := asynq.NewTask("test:task", []byte(`{}`))
    err := handler.ProcessTask(context.Background(), task)

    if err != nil {
        t.Errorf("unexpected error: %v", err)
    }
    if !strings.Contains(buf.String(), "before: test:task") {
        t.Error("expected 'before' log")
    }
}

5 集成测试

5.1 使用真实 Redis

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

    // 连接测试 Redis(使用独立 DB)
    redisOpt := asynq.RedisClientOpt{
        Addr: "localhost:6379",
        DB:   15, // 使用独立 DB,隔离测试数据
    }

    // 清理
    inspector := asynq.NewInspector(redisOpt)
    defer inspector.Close()

    // 1. 入队
    client := asynq.NewClient(redisOpt)
    defer client.Close()

    task := asynq.NewTask("test:echo", []byte(`{"msg":"hello"}`))
    info, err := client.Enqueue(task,
        asynq.MaxRetry(0),
        asynq.Retention(1*time.Minute),
    )
    if err != nil {
        t.Fatal(err)
    }

    // 2. 等待处理
    time.Sleep(2 * time.Second)

    // 3. 查询状态
    taskInfo, err := inspector.GetTaskInfo("default", info.ID)
    if err != nil {
        t.Fatal(err)
    }
    t.Logf("Task state: %s", taskInfo.State)
}

5.2 完整端到端测试

func TestE2E_TaskLifecycle(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping e2e test")
    }

    redisOpt := asynq.RedisClientOpt{Addr: "localhost:6379", DB: 15}

    // 启动 Server
    var processed atomic.Bool
    mux := asynq.NewServeMux()
    mux.HandleFunc("test:ping", func(ctx context.Context, t *asynq.Task) error {
        processed.Store(true)
        return nil
    })

    srv := asynq.NewServer(redisOpt, asynq.Config{Concurrency: 1})
    if err := srv.Start(mux); err != nil {
        t.Fatal(err)
    }
    defer srv.Shutdown()

    // 入队
    client := asynq.NewClient(redisOpt)
    defer client.Close()

    task := asynq.NewTask("test:ping", []byte(`{}`))
    _, err := client.Enqueue(task, asynq.MaxRetry(0))
    if err != nil {
        t.Fatal(err)
    }

    // 等待处理
    time.Sleep(3 * time.Second)

    if !processed.Load() {
        t.Error("task was not processed")
    }
}

6 Mock 与 Stub 模式

6.1 Mock Redis Client

对于不需要真实 Redis 的测试,可以抽象接口:

// 定义接口
type TaskClient interface {
    Enqueue(task *asynq.Task, opts ...asynq.Option) (*asynq.TaskInfo, error)
    Close() error
}

// 生产实现
type RedisTaskClient struct {
    client *asynq.Client
}

func (c *RedisTaskClient) Enqueue(task *asynq.Task, opts ...asynq.Option) (*asynq.TaskInfo, error) {
    return c.client.Enqueue(task, opts...)
}

func (c *RedisTaskClient) Close() error {
    return c.client.Close()
}

// 测试 Mock
type MockTaskClient struct {
    EnqueueCalled bool
    LastTask      *asynq.Task
}

func (m *MockTaskClient) Enqueue(task *asynq.Task, opts ...asynq.Option) (*asynq.TaskInfo, error) {
    m.EnqueueCalled = true
    m.LastTask = task
    return &asynq.TaskInfo{ID: "mock-id", Queue: "default"}, nil
}

func (m *MockTaskClient) Close() error { return nil }

6.2 测试业务逻辑(不涉及 Asynq)

// 业务逻辑独立于 Asynq Handler
func ProcessEmail(ctx context.Context, userID int, templateID string) error {
    // 实际业务逻辑
    return nil
}

// Handler 仅负责解码和调用
func HandleEmailTask(ctx context.Context, t *asynq.Task) error {
    var p EmailPayload
    if err := json.Unmarshal(t.Payload(), &p); err != nil {
        return fmt.Errorf("unmarshal: %w", asynq.SkipRetry)
    }
    return ProcessEmail(ctx, p.UserID, p.TemplateID)
}

// 测试业务逻辑时不需要构造 Task
func TestProcessEmail(t *testing.T) {
    err := ProcessEmail(context.Background(), 42, "welcome")
    if err != nil {
        t.Errorf("unexpected error: %v", err)
    }
}

💡 最佳实践:将业务逻辑从 Handler 中分离。Handler 只负责 Payload 解析和错误转换,核心逻辑放在独立的函数中便于测试。

7 测试重试逻辑

func TestRetryDecision(t *testing.T) {
    tests := []struct {
        name       string
        err        error
        wantRetry  bool
        wantSkipRetry bool
    }{
        {
            name: "network timeout → retry",
            err:  errors.New("dial tcp: i/o timeout"),
            wantRetry: true,
        },
        {
            name: "invalid payload → skip retry",
            err:  fmt.Errorf("unmarshal: %w", asynq.SkipRetry),
            wantSkipRetry: true,
        },
        {
            name: "user not found → skip retry",
            err:  fmt.Errorf("user not found: %w", asynq.SkipRetry),
            wantSkipRetry: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            isSkipRetry := errors.Is(tt.err, asynq.SkipRetry)
            if tt.wantSkipRetry != isSkipRetry {
                t.Errorf("SkipRetry = %v, want %v", isSkipRetry, tt.wantSkipRetry)
            }
        })
    }
}

8 测试辅助函数

// 快速创建测试 Task
func newTestTask(typename string, payload interface{}) *asynq.Task {
    data, _ := json.Marshal(payload)
    return asynq.NewTask(typename, data)
}

// 添加 Retry 判断辅助
func mustSkipRetry(t *testing.T, err error) {
    t.Helper()
    if !errors.Is(err, asynq.SkipRetry) {
        t.Errorf("expected SkipRetry, got: %v", err)
    }
}

func shouldRetry(t *testing.T, err error) {
    t.Helper()
    if errors.Is(err, asynq.SkipRetry) {
        t.Errorf("expected retry, but got SkipRetry")
    }
}