Skip to content
This repository has been archived by the owner on Aug 8, 2021. It is now read-only.

Writing tests

Kreato edited this page Apr 7, 2021 · 1 revision

Gord has reached the point, at which you touch one line and break another. This is pretty normal with software that grows. Especially if done in a sloppy way as with gord. Tests help this a lot, as accidentally breaking tested code is much more unlikely.

Overall, I'd categorize the things to test in three big categories:

Code unrelated to Backend and Frontend

Such code is usually quite easy to test. For example it could be a utility method for some common things you do in go. Here's a little example:

// Min returns the smaller of the passed numbers.1
func Min(a, b int) int {
    if a < b {
        return a
    }

    return b
}

Since this function is very simple and doesn't have any external dependencies, writing a test for it is very easy:

func TestMin(t *testing.T) {
    type args struct {
        a int
        b int
    }
    tests := []struct {
        name string
        args args
        want int
    }{
        {
            name: "equal numbers above 0",
            args: args{1, 1},
            want: 1,
        }, {
            name: "two zeroes",
            args: args{0, 0},
            want: 0,
        }, {
            name: "equal numbers below zero",
            args: args{-1, -1},
            want: -1,
        }, {
            name: "first is bigger",
            args: args{3, 2},
            want: 2,
        }, {
            name: "first is smaller",
            args: args{2, 3},
            want: 2,
        }, {
            name: "both negative and first is bigger",
            args: args{-2, -3},
            want: -3,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := Min(tt.args.a, tt.args.b); got != tt.want {
                t.Errorf("Min() = %v, want %v", got, tt.want)
            }
        })
    }
}

Code dependant on a Backend

If your code happens to be needing a backend (some kind of server), then it gets more difficult. In our case, this backend is usually the Discord API. The problem here is, that you don't have access to the Discord API and can't self host it, nor take parts out of it to use them for testing. So the only thing we can do, is to mock the relevant parts of the API. While this means to have some boilerplate, it's a fast and bulletproof way of testing against expected backend behaviour.

Here's another example from the gord code:

// LoadGuilds loads all guilds the current user is part of.
func LoadGuilds(session  *discordgo.Session) ([]*discordgo.UserGuild, error) {
    guilds := make([]*discordgo.UserGuild, 0)
    var beforeID string

    for {
        newGuilds, discordError := session.UserGuilds(100, beforeID, "")
        if discordError != nil {
            return nil, discordError
        }

        if len(newGuilds) != 0 {
            guilds = append(newGuilds, guilds...)
            if len(newGuilds) == 100 {
                beforeID = newGuilds[0].ID
            } else {
                return guilds, nil
            }
        } else {
            return guilds, nil
        }
    }
}

The tricky part here is, that the method depends on a functioning Discord session. However, all we need is actually the method Sesson#UserGuilds. If we rewrite the code like this:

// GuildLoader reflects an instance that allows loading guilds from a discord backend.
type GuildLoader interface {
    UserGuilds(int, string, string) ([]*discordgo.UserGuild, error)
}

// LoadGuilds loads all guilds the current user is part of.
func LoadGuilds(guildLoader GuildLoader) ([]*discordgo.UserGuild, error) {
    guilds := make([]*discordgo.UserGuild, 0)
    var beforeID string

    for {
        newGuilds, discordError := guildLoader.UserGuilds(100, beforeID, "")
        if discordError != nil {
            return nil, discordError
        }

        if len(newGuilds) != 0 {
            guilds = append(newGuilds, guilds...)
            if len(newGuilds) == 100 {
                beforeID = newGuilds[0].ID
            } else {
                return guilds, nil
            }
        } else {
            return guilds, nil
        }
    }
}

we will be able to pass any instance of a struct that satisfies the interface. Since interfaces in Go are implicit, the discordgo.Session will automatically comply with the interface and can therefore be passed as a valid parameter.

Now we can move on to writing a test for this code:

type testGuildLoader struct {
    loadFunction func(int, string, string) ([]*discordgo.UserGuild, error)
}

func (loader testGuildLoader) UserGuilds(amount int, beforeID, afterID string) ([]*discordgo.UserGuild, error) {
    return loader.loadFunction(amount, beforeID, afterID)
}

func generateGuilds(start, amount int) []*discordgo.UserGuild {
    guilds := make([]*discordgo.UserGuild, 0, amount)
    for i := start; i < start+amount; i++ {
        fmt.Println(i)
        guilds = append(guilds, &discordgo.UserGuild{ID: fmt.Sprintf("%d", i)})
    }

    return guilds
}

func TestLoadGuilds(t *testing.T) {
    type args struct {
        guildLoader GuildLoader
    }
    tests := []struct {
        name        string
        guildLoader GuildLoader
        want        []*discordgo.UserGuild
        wantErr     bool
    }{
        {
            name: "forward error",
            guildLoader: testGuildLoader{func(amount int, beforeID, afterID string) ([]*discordgo.UserGuild, error) {
                return nil, errors.New("owo, an error")
            }},
            want:    nil,
            wantErr: true,
        }, {
            name: "no guilds",
            guildLoader: testGuildLoader{func(amount int, beforeID, afterID string) ([]*discordgo.UserGuild, error) {
                return nil, nil
            }},
            want:    []*discordgo.UserGuild{},
            wantErr: false,
        }, {
            name: "100 guilds",
            guildLoader: testGuildLoader{func(amount int, beforeID, afterID string) ([]*discordgo.UserGuild, error) {
                //100 is the API limit
                if amount == 100 {
                    if beforeID == "" {
                        return generateGuilds(1, 100), nil
                    } else if beforeID == "1" {
                        return []*discordgo.UserGuild{}, nil
                    }

                    return nil, errors.New("unsupported case")
                }

                return nil, errors.New("test only supports usecase of 100 at once")
            }},
            want:    generateGuilds(1, 100),
            wantErr: false,
        }, {
            name: "150 guilds",
            guildLoader: testGuildLoader{func(amount int, beforeID, afterID string) ([]*discordgo.UserGuild, error) {
                //100 is the API limit
                if amount == 100 {
                    if beforeID == "" {
                        return generateGuilds(51, 100), nil
                    } else if beforeID == "51" {
                        return generateGuilds(1, 50), nil
                    }

                    return nil, errors.New("unsupported case")
                }

                return nil, errors.New("test only supports usecase of 100 at once")
            }},
            want:    generateGuilds(1, 150),
            wantErr: false,
        }, {
            name: "200 guilds",
            guildLoader: testGuildLoader{func(amount int, beforeID, afterID string) ([]*discordgo.UserGuild, error) {
                //100 is the API limit
                if amount == 100 {
                    if beforeID == "" {
                        return generateGuilds(101, 100), nil
                    } else if beforeID == "101" {
                        return generateGuilds(1, 100), nil
                    } else if beforeID == "1" {
                        return []*discordgo.UserGuild{}, nil
                    }

                    return nil, errors.New("unsupported case")
                }

                return nil, errors.New("test only supports usecase of 100 at once")
            }},
            want:    generateGuilds(1, 200),
            wantErr: false,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := LoadGuilds(tt.guildLoader)
            if (err != nil) != tt.wantErr {
                t.Errorf("LoadGuilds() error = %v, wantErr %v", err, tt.wantErr)
                return
            }

            if len(got) != len(tt.want) {
                t.Errorf("length of LoadGuilds() = %v, want %v", len(got), len(tt.want))
            }

            if !reflect.DeepEqual(got, tt.want) {
                t.Errorf("LoadGuilds() = %v, want %v", got, tt.want)
            }
        })
    }
}

What this test does, is basically creating a struct that complies to the GuilderLoader interface. Since we don't want to create many different struct definition for every testecase, we have one definition that allows exchanging the actual logic by simply passing a different function. In our test we simply add this function as one of our test parameters and everything will work fine.

Obviously the behaviour of our function here is tailored to our expectations, meaning that it could be different to how the real backend would react to our calls. However, we can still simulate different backend states and see whether our code behaves as expected in those cases.

Code reliant on having a visual user interface

Luckily gord is a terminal application, meaning it's rather easy to check whether the text it renders is what we expect. Well, at least it's easy in comparison to a web application or a gtk application. It's still quite tedious. Those tests are also more integration tests than unit tests, since the underlying code has many dependencies and tests how things actually play together.

Sadly gord doesn't have to many of those tests at this point in time. Here's an example:

func TestChannelTree(t *testing.T) {
    simScreen := tcell.NewSimulationScreen("UTF-8")

    simScreen.Init()
    simScreen.SetSize(10, 10)

    state := discordgo.NewState()

    c1 := &discordgo.Channel{
        ID:       "C1",
        Name:     "C1",
        Position: 2,
    }
    c2 := &discordgo.Channel{
        ID:       "C2",
        Name:     "C2",
        Position: 1,
    }
    c3 := &discordgo.Channel{
        ID:       "C3",
        Name:     "C3",
        Position: 3,
        PermissionOverwrites: []*discordgo.PermissionOverwrite{
            &discordgo.PermissionOverwrite{
                ID:   "R1",
                Type: "role",
                Deny: discordgo.PermissionReadMessages,
            },
        },
    }
    g1 := &discordgo.Guild{
        ID:   "G1",
        Name: "G1",
        Channels: []*discordgo.Channel{
            c1,
            c2,
            c3,
        },
    }
    c1.GuildID = g1.ID
    c2.GuildID = g1.ID
    c3.GuildID = g1.ID

    stateError := state.GuildAdd(g1)
    if stateError != nil {
        t.Errorf("Error initializing state: %s", stateError)
    }

    stateError = state.ChannelAdd(c1)
    if stateError != nil {
        t.Errorf("Error initializing state: %s", stateError)
    }
    stateError = state.ChannelAdd(c2)
    if stateError != nil {
        t.Errorf("Error initializing state: %s", stateError)
    }

    state.User = &discordgo.User{
        ID: "U1",
    }

    r1 := &discordgo.Role{
        ID:          "R1",
        Name:        "Rollo",
        Permissions: discordgo.PermissionReadMessages,
    }
    state.RoleAdd("G1", r1)

    state.MemberAdd(&discordgo.Member{
        GuildID: g1.ID,
        User:    state.User,
        Roles:   []string{r1.ID},
    })

    tree := NewChannelTree(state)
    loadError := tree.LoadGuild("G1")

    if loadError != nil {
        t.Errorf("Error loading channeltree: %s", loadError)
    }

    tree.SetBorder(false)
    tree.SetRect(0, 0, 10, 10)

    tree.Draw(simScreen)

    expectCell('C', 0, 0, simScreen, t)
    expectCell('2', 1, 0, simScreen, t)

    expectCell('C', 0, 1, simScreen, t)
    expectCell('1', 1, 1, simScreen, t)

    expectCell(' ', 0, 2, simScreen, t)
    expectCell(' ', 1, 2, simScreen, t)
}

func expectCell(expected rune, column, row int, screen tcell.SimulationScreen, t *testing.T) {
    cell, _, _, _ := screen.GetContent(column, row)
    if cell != expected {
        t.Errorf("Cell missmatch. Was '%c' instead of '%c'.", cell, expected)
    }
}

What it does, is manually building up the state, passing it to the UI code and rendering it to a simulated terminal. This basically allows us to then check whether the single cells of the terminal contain the expected characters.

This test, unlike most of the other tests in gord, is not a parameterized test and probably shouldn't be taken as a great example.