On this page

Testing tasks and events

Separate the decision to publish from actual delivery. Native task and handler unit tests run locally; retries, scheduling and Redis state need a real backend. Local event tests establish only process-local fanout. There is no durable local event dead-letter or replay store.

Test task construction and handlers#

Construct a task directly and inspect its native type and payload. Call its handler with a bounded context and check business invocation or asynq.SkipRetry for invalid input. Do not expect worker metadata or a ResultWriter on a newly constructed task.

go test -race ./internal/modules/identity/delivery/events ./internal/modules/identity/delivery/jobs
go test ./internal/modules/identity/mailviews ./internal/modules/identity/notifications

Identity's job tests check current-account lookup, inactive-account skipping, verified-address channel selection and retryable persistence failures. The event handler tests reject malformed JSON or missing IDs and check enqueue failures. Mailview tests render through the default layout and check escaping and plain text.

tests/modules/workflow_test.go uses real PostgreSQL and Redis to follow signup through the event, welcome job, delivery ledger and channel worker. It captures mail instead of contacting SMTP and checks both JSON database notifications and rendered email delivery. Set DATABASE_TEST_POSTGRES, QUEUE_TEST_REDIS and RATE_LIMIT_TEST_REDIS to run that suite without skips. Test retries through a real worker instead of emulating Asynq internals.

Reject malformed tasks without infrastructure#

This complete test can live in tests/identity/task_example_test.go. The malformed payload must be rejected before the handler accesses its dependencies:

package identity_test

import (
	"errors"
	"testing"

	"github.com/hibiken/asynq"

	jobhandlers "github.com/wisalgroup/labena/internal/modules/identity/delivery/jobs"
	"github.com/wisalgroup/labena/internal/modules/identity/jobs"
)

func TestMalformedWelcomeTask(t *testing.T) {
	handler := jobhandlers.Welcome{}
	task := asynq.NewTask(jobs.WelcomeType, []byte(`{"user_id":"invalid"}`))
	if err := handler.Handle(t.Context(), task); !errors.Is(err, asynq.SkipRetry) {
		t.Fatalf("expected a terminal payload error, got %v", err)
	}
}

That test establishes validation and retry classification. It does not prove task persistence, worker acknowledgement, or provider delivery. Use real dependencies for those guarantees.

Test event fanout#

This complete test can live in internal/platform/messaging/docs_example_test.go:

package messaging_test

import (
	"context"
	"testing"
	"time"

	"github.com/ThreeDotsLabs/watermill"
	"github.com/ThreeDotsLabs/watermill/message"
	"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
)

func TestIndependentSubscribers(t *testing.T) {
	ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
	defer cancel()
	bus := gochannel.NewGoChannel(gochannel.Config{OutputChannelBuffer: 1}, watermill.NopLogger{})
	defer bus.Close()
	first, err := bus.Subscribe(ctx, "example.created")
	if err != nil {
		t.Fatal(err)
	}
	second, err := bus.Subscribe(ctx, "example.created")
	if err != nil {
		t.Fatal(err)
	}
	sent := message.NewMessage("event-1", []byte(`{"id":"1"}`))
	if err := bus.Publish("example.created", sent); err != nil {
		t.Fatal(err)
	}

	for _, subscription := range []<-chan *message.Message{first, second} {
		select {
		case received, ok := <-subscription:
			if !ok || received.UUID != sent.UUID {
				t.Fatal("missing expected delivery")
			}
			received.Ack()
		case <-ctx.Done():
			t.Fatal(ctx.Err())
		}
	}
}

This uses raw subscriptions, so the test acknowledges deliveries. Normal app handlers return an error or nil and let the router acknowledge. Subscribe before publishing; a bounded channel wait verifies progress without arbitrary sleeps.

Test real drivers#

Start disposable Redis on a dedicated test port in another terminal:

docker run --rm --name go-modular-queue-test -p 16379:6379 redis:7-alpine

Run the existing suites:

QUEUE_TEST_REDIS=redis://127.0.0.1:16379/1   go test -race -count=1 -v ./tests/app ./tests/queue ./pkg/asynqx/... ./internal/platform/cli

Stop that container with Ctrl+C afterward. Use a disposable Redis instance, not production credentials. These suites use isolated task/queue identities and clean their work; missing QUEUE_TEST_REDIS causes explicit skips.

Cover the delivery contract#

Scenario Verify
Enqueue and execute Intended handler receives the native task
Transient failure Retry policy and eventual result
Invalid payload No business effect; terminal task state
Result retention Stored bytes and task state match
Cancellation Handler observes cancellation; work actually exits
Shutdown Worker joins before borrowed clients close
Scheduling Registration, queue routing and due publication
Trace propagation Producer link without losing consumer cancellation

A local fake cannot establish Redis recovery or crash durability. Likewise, one successful scheduled run does not establish multi-scheduler coordination. The app requires one scheduler per schedule set.

Run database tests separately for SQL consistency and cache storage. Neither backend test makes SQL and Redis publication atomic.

Verify the notification ledger separately#

An Asynq task can finish after persisting notification intent while channel delivery is still pending. Assert the layer you intend to verify:

Boundary Evidence
Welcome handler Expected channel intents and stable occurrence key
Transactional notification Business change and intents commit or roll back together
Dispatcher Due committed intent is submitted with its delivery ID
Channel worker Leases, retries, and terminal/receipt state follow the provider outcome
SMTP/Expo integration Captured request/content under a fake; live provider behavior requires separate evidence

Tests should cover duplicate task execution and an omitted versus stable notification key. Use fixture mail/push senders so ordinary automated tests do not contact real recipients. A provider's accepted response is not proof of inbox or device display.

Verify local event limits and exhaustion#

The app uses messaging.LocalBus with its DeliveryMiddleware outside retry middleware. Test capacity rejection, bounded handler concurrency, cancellation and one terminal failure after the configured retry cycle. Failed cycles are logged and settled; admission capacity must become available again. A native GoChannel fixture without this adapter can still redeliver Nacked messages, so it does not verify the app's exhaustion policy.

For reactions requiring restart recovery, use and test a deliberate durable-intent workflow. Local publication remains best-effort. See events.

Documentation

Search guides, sections, and API names.