콘텐츠로 건너뛰기

How to Write Go Testing

  • go

Intro

In making your application with code, it is strongly recommended to write test cases. Because it can make sure that your whole bunch of codes work correctly and for your mind health. Recently I have a chance to work in great project named it-chain-Engine with golang. In shortly this project is about lightweight and completely customizable blockchain engine.

Completely customizable, because this project is implemented with DDD & event sourcing way which can separate every component with bounded context and each component is composed with lots of layers. And to make sure those layers as well as micro components which consists of layer are interact well, writing test case is best. So how can we write go testing? There are some ways and I’ll post some tips with my experience.

Use the “underscore test” package

package blockchain
func TestBlockPoolModel(t *testing.T) {
    pool := NewBlockPool()
    block1 := &DefaultBlock{
        Height: BlockHeight(2),
    }
    ...
}
package blockchain_test
func TestBlockPoolModel(t *testing.T) {
    pool := blockchain.NewBlockPool()
    block1 := &blockchain.DefaultBlock{
        Height: blockchain.BlockHeight(2),
    }
    ...
}

Those codes are almost same. Then what is difference? What is good thing when writing “underscore test” package name?

One thing is we can write test cases like when we really use that codes. When we use BlockPoolModel in other package, we write codes like blockchain.NewBlockPool(), not NewBlockPool(). This provides us the real experience as we actually write some codes.

Also there is another advantage that provide exception which golang files in the same directory belong to the same package. In other words, you can put block_pool.go and block_pool_test.go in the same directory named blockchain with different package name blockchain and blockchain_test.

Test Helpers

When testing, we may need to setup test fixture like connecting to db, mq, providing mock datas. So we are writing that codes over and over although actual testing codes really short. You may can think ‘How about refactor those codes’ like JUnit @Before or mocha before method. We can implement it using defer.

func TestInvokeInitA(t *testing.T) {
    port : = "50011"
    defer SetupTest(t, port)()
    ...
}
func SetupTest(t *testing.T, port string) func() {
    t.Log("before")
    GOPATH := os.Getenv("GOPATH")
    cmd := exec.Command(...)
    err := cmd.Run()
    assert.NoError(t, err)
    ...
    return func() {
        t.Log("after")
        if err := syscall.Kill(-cmd2.Process.Pid, syscall.SIGINT); err != nil {
            t.Fatal("failed to kill process: ", err)
        }
        os.RemoveAll("./db")
    }
}

Here before writing actual test codes you can setup fixture, in this case executing cmd commands works as JUnit’s @Before. And in SetupTest you can pass testing.T, so you can also test whether our test fixtures are setup well. Also as you can see SetupTest returns function. In returned function you can clean up fixture like close db connection, remove files and so on. By refactor setup function you can increase readability of your test codes and can see what is going on in those test cases at once. And the best thing is you can save your effort to write boring setup codes.

Table Driven Tests

How about testing about several cases with only one setting? You may can take into account Table Driven Tests. Let’s start with simple example and see what is it.

func TestFib(t *testing.T) {
    var fibTests = []struct {
        n           int
        expected    int
    } {
        {1, 1},
        {2, 1},
        {3, 2},
        {4, 3},
        {5, 5},
        {6, 8},
        {7, 13},
    }
    for _, tt := range fibTests {
        actual := Fib(tt, n)
        if actual != tt.expected {
            t.Error(...)
        }
    }
}

We can see fibTests slice (table) which provides test input and output. And every iteration you can test with given input, output. Advantage of this approach is you can test lot of cases with one actual test case codes and there’s no need much effort to add test cases, just add one row of table. Let’s see another one.

// it-chain-Engine/blockchian/infra/adapter/command_handler_test.go
func TestCommandHandler_HandleConfirmBlockCommand(t *testing.T) {
    tests := map[string] struct {
        input struct {
            command blockchain.ConfirmBlockCommand
        }
        err error
    } {
        "success": {
            input: struct {
                command blockchain.ConfirmBlockCommand
            } {
                command: blockchain.ConfirmBlockCommand{
                    CommandModel: midgard.CommandModel{ID: "zf"},
                    Block: &blockchain.DefaultBlock{
                        Height: 99887,
                    },
                },
            },
            err: nil,
        },
        "block nil error test": {
            input: struct {
                command blockchain.ConfirmBlockCommand
            } {
                command: blockchain.ConfirmBlockCommand{
                    CommandModel: midgard.CommandModel{ID: "zf"},
                    Block: nil,
                },
            },
            err: adapter.ErrBlockNil,
        },
    }
    blockApi := MockBlockApi{}
    blockApi.AddBlockToPoolFunc = func(block blockchain.Block) error {
        assert.Equal(t, block.GetHeight(), uint64(99887))
        return nil
    }
    commandHandler := adapter.NewCommandHandler(blockApi)
    for testName, test := range tests {
        t.Logf("running test case %s", testName)
        err := commandHandler.HandleConfirmBlockCommand(test.input.command)
        assert.Equal(t, err, test.err)
    }
}

This is rather complicate example. First, used map instead of slice. As you can see key is used as test case description and by using value as struct you can explicitly indicate what is input, output and error. Below the table is injecting mock object to command handler which is what you are going to test. Mocking will be explained in next section. Finally in the for loop, there’s actual test codes commandHandler.HandleConfirmeBlockCommand(test.input.command).

Downsides: readability

This is about Table Driven Tests. And yes, there’s advantages with this technique but also exists downside. The point is readability, as you may noticed in second example, table’s input, output, error row can be large in real-world testing and more importantly you can’t know what is going to do with these huge inputs. You can find out where all of these inputs are going to use at the point of actual test codes.

Using Mock

type TxEventHandler struct {
    txRepository txpool.TransactionRepository
    leaderRepository txpool.LeaderRepository
}
func NewTxEventHandler(tr txpool.TransactionRepository, lr txpool.LeaderRepository) *TxEventHandler {
    return &TxEventHandler{
        txRepository: txRepository,
        leaderRepository: leaderRepository,
    }
}
// Save transaction to txRepository
func (t TxEventHandler) HandleTxCreatedEvent(txCreatedEvent txpool.TxCreatedEvent) error {
    txID := txCreatedEvent.ID
    if txID == "" {
        return ErrNoEventID
    }
    tx := txCreatedEvent.GetTransaction()
    // danger! t.txRepository.Save(tx) may can affect to our test results.
    err := t.txRepository.Save(tx)
    if err != nil {
        return err
    }
    return nil
}

Let’s assume that we want to test HandleTxCreatedEvent function. This function take txCreatedEvent as parameters and from this event we get target transaction to save. And with this transaction we save it to TransactionRepository.

But we do not know how TransactionRepository is implemented. So TransactionRepository may can affect to our test results. We should take control of txRepository.Save so that we can make sure the cause of failure of tests are not from TransactionRepository. And this is where Mock is come into play. We can see constructor receives TransactionRepository and create EventHandler object. And instead of injecting real TransactionRepository we could inject our own mocking TransactionRepository. As a result t.txRepository.Save(tx) could trigger our own defining function which helps tests.

// implement TransactionRepository interface
type MockTransactionRepository struct {
    SaveFunc func(transaction txpool.Transaction) error
}
func (m MockTransactionRepository) Save(transaction txpool.Transaction) error {
    return m.SaveFunc(transaction)
}
func TestTxEventHandler_HandleTxCreatedEvent(t *testing.T) {
    ...
    mockTxRepo := MockTransactionRepository{}
    mockTxRepo.SaveFunc = func(transaction txpool.Transaction) error {
        // We can assert about transaction
        assert.Equal(...)
        return nil
    }
    // Create EventHandler with our own MockTxRepo
    eventHandler := adapter.NewTxEventHandler(mockTxRepo, mockLeaderRepo);
    ...
}

With mocking, test goes like this. We create MockTransactionRepository struct which implements TransactionRepository so that we could inject this mock into our testing eventHandler. Next is declare functions we need to control, in this case SaveFunc which is triggered when MockTransactionRepository.Save is called. And those functions are defined inside test cases for our own tastes, we could assert about parameters and could return specific errors for some case. Finally inject those mocking object into our testing object: eventHandler.

Downsides: too many mocks

This methodolgy surly has downsides. We could think of testing object but that object receive too many parameters in constructor and for take control of those parameter object we should make all of them as mock, also implement mock object’s functions. So for one easy test case, we may need to write hundreds of codes for mocking object and this is super waste, and we are lazy.

But we need to think about this first! Those objects which need many mocking object may have too many responsibilities and this is not good design. We should think about separating those responsibility by separating object and this is about ‘Single Responsibility Principle’.

And the other way to solve this problem is redeclaring interface. We may not need all of the functions declared in TransactionRepository. We may only need write functions for this interface. So we could fix like this.

// TransactionRepository implement this.
type WriteOnlyTransactionRepository interface {
    Save(transaction txpool.Transaction)
}
type TxEventHandler struct {
    txRepository txpool.WriteOnlyTransactionRepository
    leaderRepository txpool.LeaderRepository
}
func NewTxEventHandler(tr txpool.WriteOnlyTransactionRepository, lr txpool.LeaderRepository) *TxEventHandler {
    return &TxEventHandler{
        txRepository: txRepository,
        leaderRepository: leaderRepository,
    }
}
// Save transaction to txRepository
func (t TxEventHandler) HandleTxCreatedEvent(txCreatedEvent txpool.TxCreatedEvent) error {
    txID := txCreatedEvent.ID
    if txID == "" {
        return ErrNoEventID
    }
    tx := txCreatedEvent.GetTransaction()
    // danger! t.txRepository.Save(tx) may can affect to our test results.
    err := t.txRepository.Save(tx)
    if err != nil {
        return err
    }
    return nil
}

By creating WriteOnlyTransactionRepository, not only there’s just one function to implement in mock object but this could see that this repository only works for writing things at once and also separate responsibility. I think this is better design.

Leave a comment