Discord Twitter GitHub

Lower Test Toil With Better Local Testing

Service Weaver is a programming framework that makes it easy to write, test, and deploy distributed applications. In previous blog posts, we discussed how to write and deploy Service Weaver applications. In this blog post, we'll focus on testing. Specifically, we'll explore unit tests, integration tests, and randomized tests.

  1. Service Weaver lets you unit test components of your system using idiomatic Go unit tests.
  2. Service Weaver makes it significantly easier to write integration tests that would otherwise be slow and brittle. Later in this blog post, we present an example where Service Weaver speeds up an integration test by a factor of 10,000, decreasing the test time from 20 minutes to 0.02 seconds.
  3. Service Weaver implements an advanced form of randomized testing called deterministic simulation that can find rare bugs that only emerge in pathological cases. Without deterministic simulation, these bugs are incredibly difficult to catch, and often arise in production with catastrophic consequences.

These three types of testing—unit testing, integration testing, and randomized testing—all fall on a spectrum trading off test complexity and test coverage. In the remainder of this blog post, we'll take a closer look at these three types of tests in the context of Service Weaver.

1. Unit Testing

Service Weaver has a weavertest package that makes it easy to write both unit tests and integration tests. For example, consider the following Adder component:

type Adder interface {
    Add(context.Context, int, int) (int, error)
}

type adder struct {
    weaver.Implements[Adder]
}

func (*adder) Add(_ context.Context, x, y int) (int, error) {
    return x + y, nil
}

To unit test the Adder component using idiomatic Go unit tests, we can use the weavertest package to write a test as follows:

package main

import (
    "context"
    "testing"

    "github.com/ServiceWeaver/weaver"
    "github.com/ServiceWeaver/weaver/weavertest"
)

func TestAdd(t *testing.T) {
    // Run the Adder component locally, and test that 1 + 2 = 3.
    runner := weavertest.Local
    runner.Test(t, func(t *testing.T, adder Adder) {
        got, err := adder.Add(context.Background(), 1, 2)
        if err != nil {
            t.Fatal(err)
        }
        if want := 3; got != want {
            t.Fatalf("got %q, want %q", got, want)
        }
    })

In the code above, weavertest.Local is a unit test runner that runs all components locally. The call to runner.Test executes the provided unit test, instantiating all the necessary components (i.e. Adder).

Because unit tests written with the weavertest package are standard Go unit tests, you can run them (as you would any unit test) with go test:

$ go test
PASS
ok      github.com/ServiceWeaver/adder 0.001s

2. Integration Testing

While unit tests are good for testing small pieces (or units) of an application in isolation, integration tests test an application in its entirety. Unfortunately, integration testing microservice based applications can be toilsome.

To integration test an application with n microservices, a testing framework needs to build, run, and interconnect all n microservice binaries. Plus, the integration tests themselves have to be written in yet another binary that interacts with the system over the network. Overall, integration testing is historically slow, brittle, and tedious.

Consider Online Boutique, as an example. Online Boutique is an example microservice based application that implements a simple e-commerce app where users browse items, add items to their cart, and check out. Online Boutique consists of eleven microservices written in five different programming languages. Running the application locally requires 4 CPUs, 4.0 GiB of memory, 32 GB of disk space, a locally running Kubernetes cluster, and over 8000 lines of YAML and Dockerfiles. The Online Boutique documentation explains that the application can take upwards of 20 minutes to start running the first time. And this is just to run the application. Integration testing it would require even more effort.

In contrast to this, we ported Online Boutique to Service Weaver and are able to integration test the application with a single command and zero lines of config in less than a tenth of a second. To do so, we again use the weavertest package:

package main

import (
    "context"
    "testing"
    "time"

    "github.com/ServiceWeaver/onlineboutique/cartservice"
    "github.com/ServiceWeaver/onlineboutique/checkoutservice"
    "github.com/ServiceWeaver/onlineboutique/paymentservice"
    "github.com/ServiceWeaver/onlineboutique/productcatalogservice"
    "github.com/ServiceWeaver/onlineboutique/shippingservice"
    "github.com/ServiceWeaver/weaver/weavertest"
)

func TestPurchase(t *testing.T) {
    runner := weavertest.Local
    runner.Test(t, func(
        t *testing.T,
        catalog productcatalogservice.ProductCatalogService,
        cart cartservice.CartService,
        checkout checkoutservice.CheckoutService,
    ) {
        // List all products.
        ctx := context.Background()
        products, err := catalog.ListProducts(ctx)
        if err != nil {
            t.Fatal(err)
        }

        // Add one of every product to our cart.
        const userID = "sundar_pichai"
        for _, product := range products {
            item := cartservice.CartItem{ProductID: product.ID, Quantity: 1}
            if err := cart.AddItem(ctx, userID, item); err != nil {
                t.Fatal(err)
            }
        }

        // Place the order.
        order := checkoutservice.PlaceOrderRequest{
            UserID:       userID,
            UserCurrency: "USD",
            Address: shippingservice.Address{
                StreetAddress: "1600 Amphitheatre Parkway",
                City:          "Mountain View",
                State:         "CA",
                Country:       "USA",
                ZipCode:       94043,
            },
            Email: "sundar@google.com",
            CreditCard: paymentservice.CreditCardInfo{
                Number:          "4432-8015-6152-0454",
                CVV:             672,
                ExpirationYear:  2025,
                ExpirationMonth: time.January,
            },
        }
        if _, err := checkout.PlaceOrder(ctx, order); err != nil {
            t.Fatal(err)
        }
    })
}

The integration test lists all available products using the catalog service, adds one of every product to a cart using the cart service, and checks out using the checkout service. Service Weaver automatically initializes all these services, as well as the other services on which they depend (e.g., shipping service, email service, payment service, etc.). Moreover, Service Weaver reduces the tedium of writing integration tests by allowing you to use language native types and method calls to interact with various services.

We can run this integration test as easily as running go test:

$ go test
PASS
ok      github.com/ServiceWeaver/onlineboutique 0.026s

In addition to using the weavertest.Local runner, which runs all components locally in a single process, we can also use

Some components may be too slow or cumbersome to use in a test. In these cases, Service Weaver allows you to replace the component with a fake. For example, we can write a fake implementation of the product catalog service that uses a fixed in-memory set of products:

type Product = productcatalogservice.Product

type fakeCatalog struct {
    products []Product
}

func (f *fakeCatalog) ListProducts(context.Context) ([]Product, error) {
    return f.products, nil
}

func (f *fakeCatalog) GetProduct(ctx context.Context, id string) (Product, error) {
    for _, p := range f.products {
        if p.ID == id {
            return p, nil
        }
    }
    return Product{}, productcatalogservice.NotFoundError{}
}

func (f *fakeCatalog) SearchProducts(context.Context, string) ([]Product, error) {
    panic("unimplemented")
}

Then, we can update our integration test to use a fakeCatalog instead of the real product catalog service component.

func TestPurchaseWithFakeCatalog(t *testing.T) {
    catalog := &fakeCatalog{
        products: []Product{
            {
                ID:          "0",
                Name:        "Noogler Hat",
                Description: "A colorful hat given to new Googlers.",
                PriceUSD:    money.T{CurrencyCode: "USD", Units: 42},
            },
        },
    }
    fake := weavertest.Fake[productcatalogservice.ProductCatalogService](catalog)
    runner := weavertest.Local
    runner.Fakes = append(runner.Fakes, fake)
    runner.Test(t, func(
        t *testing.T,
        catalog productcatalogservice.ProductCatalogService,
        cart cartservice.CartService,
        checkout checkoutservice.CheckoutService,
    ) {
        // Testing code goes here.
        ...
    })
}

3. Randomized Testing

Why Randomized Testing Is Important

When you write a unit test or integration test, you write some code and check that it executes in the way you expect. For example, to test a Reversed([]byte) []byte function, you might check that

Unit and integration tests are thus limited to only test the behaviors and corner cases that you can think of and have the patience to write down.

With randomized property-based tests, on the other hand, you specify a property of your code that should always hold and then test the property on millions of randomly generated examples. For example, we can test that the Reversed function is involutive—i.e. that Reversed(Reversed(x)) == x—by writing a fuzz test that tests the property on randomly generated byte slices:

func FuzzReversedIsInvolutive(f *testing.F) {
    f.Fuzz(func(t *testing.T, want []byte) {
        got := Reversed(Reversed(want))
        if !slices.Equal(want, got) {
            t.Fatalf("got %v, want %v", got, want)
        }
    })
}

Service Weaver takes fuzz testing to the next level and allows you to apply randomized property-based testing to entire applications by checking that properties of an application always hold, even when we run random operations on random inputs in the face of random failures and random interleavings. For distributed systems, this kind of randomized property-based testing is especially valuable, as many failure inducing corner cases are extremely pathological and hard to think of. Even well studied and heavily scrutinized protocols tend to have subtle bugs.

Deterministic Simulation

Service Weaver implements a type of randomized testing called deterministic simulation, popularized by FoundationDB. Deterministic simulation has the ability to (1) find rare, buggy executions of a system and (2) deterministically replay these executions. This allows you to step through a buggy execution, understand the bug, write a bug fix, and verify that the fix eliminates the bug.

To deterministically simulate a system without Service Weaver, you'll have to implement a simulator from scratch. There aren't any existing tools that make it easy to perform deterministic simulation. Service Weaver, on the other hand, ships with a full deterministic simulation implementation.

Details

Deterministic simulation requires three things: a system to test, a workload, and a set of invariants.

  1. The system to test is self-explanatory. With Service Weaver, these are Service Weaver applications. As an example, consider a banking application which allows users to deposit and withdraw money from their accounts.

  2. You must specify a set of operations to run against the system. Continuing our banking example, you could define an operation to deposit a random amount of money into a random account and an operation to withdraw a random amount of money from a random account. Together, the set of operations is called a workload.

  3. You must define a set of system invariants. These are properties about your system that must always hold. For the banking application, you might define the invariant that a user should never have a negative bank account balance.

Given a system to test, a workload, and a set of invariants, a deterministic simulator runs millions of random operations—drawn from the workload—against the system, checking that the invariants always hold. While the simulator runs operations, it also performs all sorts of nefarious actions like injecting network delays, re-ordering messages, and artificially failing services.

While these executions are randomized and have injected failures, they are also deterministic. When a simulator discovers an invariant violation, it can report the exact sequence of events that led to the violation and can replay the sequence on command. This allows you to debug what went wrong in your system, fix it, and then replay the failure inducing execution to ensure your fix is correct.

A simulator can even simplify a failing execution to the smallest subsequence of events that leads to an invariant violation, a process known as minimization. This makes it easier to understand what caused an invariant violation while ignoring the unrelated events that just happened to be thrown in the mix.

Banking Example

Now, let's look at how to, concretely, perform deterministic simulation with Service Weaver. We implement and test the banking example presented above. We begin with a Store component that persists a mapping from strings to integers:

// A Store is a persistent map from strings to integers, like a map[string]int.
type Store interface {
    // Get gets the value of the provided key.
    Get(ctx context.Context, key string) (int, error)

    // Add atomically adds the provided delta to the provided key and returns
    // the resulting sum. Note that delta can be positive or negative. For
    // example, Add(ctx, "foo", 10) adds 10 to "foo", while Add(ctx, "foo",
    // -10) subtracts 10 from "foo".
    Add(ctx context.Context, key string, delta int) (int, error)
}

The Store component can be implemented using any persistent data store, like a relational database, for example. We omit the implementation for brevity. Next, we have a Bank component with an API to deposit and withdraw money:

// A Bank is a persistent collection of user bank account balances.
type Bank interface {
    // Deposit adds the provided amount to the provided user's bank account
    // balance and returns the balance after the deposit.
    //
    // Deposit returns an error if the provided amount is negative.
    Deposit(ctx context.Context, user string, amount int) (int, error)

    // Withdraw subtracts the provided amount from the provided user's bank
    // account balance and returns the balance after the withdrawal.
    //
    // Withdraw returns an error if the provided amount is negative or if the
    // user's balance is less than the withdrawal amount.
    Withdraw(ctx context.Context, user string, amount int) (int, error)
}

We implement the Bank component using the Store component as follows:

type bank struct {
    weaver.Implements[Bank]
    store weaver.Ref[Store]
}

func (b *bank) Deposit(ctx context.Context, user string, amount int) (int, error) {
    if amount < 0 {
        return 0, fmt.Errorf("deposit negative amount: %d", amount)
    }
    return b.store.Get().Add(ctx, user, amount)
}

func (b *bank) Withdraw(ctx context.Context, user string, amount int) (int, error) {
    if amount < 0 {
        return 0, fmt.Errorf("withdraw negative amount: %d", amount)
    }
    balance, err := b.store.Get().Get(ctx, user)
    if err != nil {
        return 0, err
    }
    if amount > balance {
        return 0, fmt.Errorf("insufficient funds (%d) to withdraw %d", balance, amount)
    }
    return b.store.Get().Add(ctx, user, -amount)
}

Note that the Withdraw method is careful not to withdraw more money than a user has in their account. A user should never have a negative bank account balance.

Next, we test our banking app using Service Weaver's sim package. We begin by writing a fake implementation of the Store component to simplify testing:

type fakestore struct {
    mu     sync.Mutex
    values map[string]int
}

func (f *fakestore) Get(_ context.Context, key string) (int, error) {
    f.mu.Lock()
    defer f.mu.Unlock()
    return f.values[key], nil
}

func (f *fakestore) Add(_ context.Context, key string, delta int) (int, error) {
    f.mu.Lock()
    defer f.mu.Unlock()
    f.values[key] += delta
    return f.values[key], nil
}

Next, we define a workload consisting of random deposits and withdrawals. With the sim package, a workload is implemented as a struct that implements the sim.Workload interface.

// BankWorkload is a workload that performs random deposits and withdrawals.
type BankWorkload struct {
    bank weaver.Ref[bank.Bank]
}

The sim.Workload interface includes an Init method where the workload can register fake component implementations and random generators.

// Init implements the sim.Workload interface.
func (c *BankWorkload) Init(r sim.Registrar) error {
    // Register generators that deposit and withdraw between $0 and $100 from
    // alice and bob's bank accounts.
    user := sim.OneOf("alice", "bob")
    amount := sim.Range(0, 100)
    r.RegisterGenerators("Deposit", user, amount)
    r.RegisterGenerators("Withdraw", user, amount)

    // Register a fake store with $100 in alice and bob's accounts initially.
    store := &fakestore{values: map[string]int{"alice": 100, "bob": 100}}
    r.RegisterFake(sim.Fake[bank.Store](store))
    return nil
}

Finally, we implement the actual workload operations, Deposit and Withdraw, as methods of our workload. During simulation, the arguments to the methods are generated by the random generators registered in the Init method. If an operation ever detects an invariant violation, it returns an error. For this example, Withdraw returns an error if it ever detects a negative bank account balance.

// Deposit is an operation that deposits the provided amount in the provided
// user's bank account balance.
func (c *BankWorkload) Deposit(ctx context.Context, user string, amount int) error {
    // NOTE that we ignore errors because it is expected that the simulator
    // will inject errors every once in a while.
    c.bank.Get().Deposit(ctx, user, amount)
    return nil
}

// Withdraw is an operation that withdraws the provided amount from the
// provided user's bank account balance.
func (c *BankWorkload) Withdraw(ctx context.Context, user string, amount int) error {
    balance, err := c.bank.Get().Withdraw(ctx, user, amount)
    if err != nil {
        // NOTE that we ignore errors because it is expected that the simulator
        // will inject errors every once in a while.
        return nil
    }
    if balance < 0 {
        // A bank account balance should never be negative.
        return fmt.Errorf("user %s has negative balance %d", user, balance)
    }
    return nil
}

Finally, we write a test to run the simulation, which we can run with go test:

func TestBank(t *testing.T) {
    s := sim.New(t, &BankWorkload{}, sim.Options{})
    r := s.Run(10 * time.Second)
    if r.Err != nil {
        t.Log(r.Mermaid())
        t.Fatal(r.Err)
    }
}

If we run this test, we'll see it fails!

go test -v
=== RUN   TestBank
    simulator.go:288: Simulating workload *bank_test.BankWorkload for 10s.
    simulator.go:478: Executing 0 graveyard entries.
    simulator.go:498: Done executing graveyard entries.
    simulator.go:382: Executing with 240 executors.
    simulator.go:315: Error found after 135,022 ops across 133,504 executions in 988ms (135,044.16 execs/s, 136,579.67 ops/s).
    simulator.go:326: Failing input written to testdata/sim/TestBank/62af1e314c4de114.json.
    bank_test.go:103: sequenceDiagram
            participant op1 as Op 1
            participant op2 as Op 2
            participant github.com/ServiceWeaver/weaver/sim/internal/bank/Bank0 as bank.Bank 0
            participant github.com/ServiceWeaver/weaver/sim/internal/bank/Store0 as bank.Store 0
            note right of op1: [1:1] Withdraw(alice, 58)
            op1->>github.com/ServiceWeaver/weaver/sim/internal/bank/Bank0: [1:2] bank.Bank.Withdraw(alice, 58)
            github.com/ServiceWeaver/weaver/sim/internal/bank/Bank0->>github.com/ServiceWeaver/weaver/sim/internal/bank/Store0: [1:3] bank.Store.Get(alice)
            github.com/ServiceWeaver/weaver/sim/internal/bank/Store0->>github.com/ServiceWeaver/weaver/sim/internal/bank/Bank0: [1:3] return 100, <nil>
            note right of op2: [2:5] Withdraw(alice, 45)
            op2->>github.com/ServiceWeaver/weaver/sim/internal/bank/Bank0: [2:6] bank.Bank.Withdraw(alice, 45)
            github.com/ServiceWeaver/weaver/sim/internal/bank/Bank0->>github.com/ServiceWeaver/weaver/sim/internal/bank/Store0: [2:7] bank.Store.Get(alice)
            github.com/ServiceWeaver/weaver/sim/internal/bank/Store0->>github.com/ServiceWeaver/weaver/sim/internal/bank/Bank0: [2:7] return 100, <nil>
            github.com/ServiceWeaver/weaver/sim/internal/bank/Bank0->>github.com/ServiceWeaver/weaver/sim/internal/bank/Store0: [1:4] bank.Store.Add(alice, -58)
            github.com/ServiceWeaver/weaver/sim/internal/bank/Store0->>github.com/ServiceWeaver/weaver/sim/internal/bank/Bank0: [1:4] return 42, <nil>
            github.com/ServiceWeaver/weaver/sim/internal/bank/Bank0->>op1: [1:2] return 42, <nil>
            note right of op1: [1:1] return <nil>
            github.com/ServiceWeaver/weaver/sim/internal/bank/Bank0->>github.com/ServiceWeaver/weaver/sim/internal/bank/Store0: [2:8] bank.Store.Add(alice, -45)
            github.com/ServiceWeaver/weaver/sim/internal/bank/Store0->>github.com/ServiceWeaver/weaver/sim/internal/bank/Bank0: [2:8] return -3, <nil>
            github.com/ServiceWeaver/weaver/sim/internal/bank/Bank0->>op2: [2:6] return -3, <nil>
            note right of op2: [2:5] return user alice has negative balance -3

    bank_test.go:104: Unexpected success
--- FAIL: TestBank (0.99s)
FAIL
exit status 1
FAIL    github.com/ServiceWeaver/weaver/sim/internal/bank       1.015s

The simulator writes the failing execution to a file (testdata/sim/TestBank/62af1e314c4de114.json in this example). The next time you run go test, the simulator will automatically parse and replay the failing execution. The simulator also prints out a Mermaid diagram of the failing execution (which you can view using mermaid.live):

A diagram showing an execution of a banking application that resulted in a negative bank account balance.

The diagram shows two operations that race to withdraw money from Alice's account. The first operation sees Alice's balance at $100, concluding that a withdrawal of $63 is safe. However, before it can actually perform the withdrawal, the second operation starts executing. The second operation also sees Alice's bank account balance at $100 and successfully withdraws $76, leaving Alice with $24. Then, the first operation resumes execution, withdrawing $63 and leaving Alice with a negative balance of -$39, an invariant violation!

This execution highlights a bug in our banking app. The Withdraw method checks a user's balance and then performs the withdrawal if safe, but these two steps need to be performed transactionally. This type of bug—the type that only arises on a very specific interleaving of a very specific set of requests—is hard to find and diagnose without deterministic simulation.