Project: Building a Card Deck in Go (Custom Types, Files and Tests)
A hands-on project: custom types with methods, dealing with slices, file I/O, a correct shuffle and unit tests with go test.
On this page
Small projects teach more than isolated snippets. In this one we build a deck of cards that can be created, printed, dealt, shuffled, saved to a file and loaded back, then we write unit tests for it. Along the way you will use custom types, receiver functions, multiple return values, file I/O and Go's built-in testing tool.
A custom type based on a slice
You can define your own type on top of any existing type. Here a deck is a slice of strings, so every slice operation still works, but we can attach methods to it.
type deck []string
func newDeck() deck {
cards := deck{}
suits := []string{"Spades", "Hearts", "Diamonds"}
values := []string{"Ace", "Two", "Three"}
for _, suit := range suits {
for _, value := range values {
cards = append(cards, value+" of "+suit)
}
}
return cards
}newDeck is the conventional constructor. Three suits and three values give 9 cards.
Receiver functions
func (d deck) print() {
for i, card := range d {
fmt.Println(i, card)
}
}Any variable of type deck now has a print method: cards.print(). The receiver d is like this or self in other languages, but it is just a parameter, and by convention it has a short name.
Dealing: returning two values
Slicing makes dealing trivial. A hand is the first handSize cards; the rest stay in the deck.
func deal(d deck, handSize int) (deck, deck) {
return d[:handSize], d[handSize:]
}
hand, remaining := deal(cards, 5)hand and remaining share the original backing array (see the slices article). If you later append to hand, it can overwrite the first card of remaining. Clone if the two must be independent: slices.Clone(d[:handSize]).It is also worth validating input: deal(cards, 50) would panic with "slice bounds out of range". Returning an error is friendlier.
Saving to and loading from a file
To write to disk we convert the deck to a single string, then to []byte, which is what os.WriteFile expects.
func (d deck) toString() string {
return strings.Join([]string(d), ",")
}
func (d deck) saveToFile(filename string) error {
return os.WriteFile(filename, []byte(d.toString()), 0o644)
}
func newDeckFromFile(filename string) (deck, error) {
bs, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("read deck: %w", err)
}
return deck(strings.Split(string(bs), ",")), nil
}Two improvements over my original version:
log.Fatal + os.Exit. A library function should never kill the whole program; the caller decides what to do. (Also, log.Fatal already exits, so the os.Exit(1) after it never ran.)0o644 (owner read/write, everyone else read) are a safer default than 0666.fmt.Errorf with %w wraps the original error, so callers can still check it with errors.Is(err, fs.ErrNotExist).
Shuffling correctly
The original shuffle looked like this:
// buggy version
for i := range d {
j := r.Intn(len(d) - 1) // never returns the last index!
d[i], d[j] = d[j], d[i]
}Intn(n) returns a number in [0, n), so Intn(len(d)-1) can never pick the last card, and "swap every position with a random one" is also statistically biased. The correct algorithm is the Fisher-Yates shuffle, which the standard library already implements:
func (d deck) shuffle() {
rand.Shuffle(len(d), func(i, j int) {
d[i], d[j] = d[j], d[i]
})
}Since Go 1.20 the global math/rand generator is seeded automatically, so you no longer need rand.Seed(time.Now().UnixNano()). For new code, math/rand/v2 is the modern package. (Neither is suitable for security; use crypto/rand for tokens and secrets.)
Notice that shuffle uses a value receiver but still changes the caller's deck. The receiver is a copy of the slice header, which points to the same backing array, so swapping elements is visible to the caller.
Testing the deck
Go's testing tool is built in. Test files end in _test.go, and test functions start with Test and take a *testing.T.
package main
import (
"path/filepath"
"testing"
)
func TestNewDeck(t *testing.T) {
d := newDeck()
if len(d) != 9 {
t.Fatalf("expected 9 cards, got %d", len(d))
}
if d[0] != "Ace of Spades" {
t.Errorf("expected first card 'Ace of Spades', got %q", d[0])
}
if d[len(d)-1] != "Three of Diamonds" {
t.Errorf("expected last card 'Three of Diamonds', got %q", d[len(d)-1])
}
}
func TestSaveToFileAndNewDeckFromFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "deck.txt") // cleaned up automatically
if err := newDeck().saveToFile(path); err != nil {
t.Fatal(err)
}
loaded, err := newDeckFromFile(path)
if err != nil {
t.Fatal(err)
}
if len(loaded) != 9 {
t.Errorf("expected 9 cards, got %d", len(loaded))
}
}go test ./... # run all tests
go test -v -run Deck # verbose, only tests matching "Deck"
go test -cover # show coveraget.TempDir() gives each test a fresh directory that Go deletes afterwards, which is cleaner than creating and removing _decktesting by hand. t.Fatal stops the test immediately; t.Error records a failure and keeps going.
Key takeaways
type deck []string) to attach methods.log.Fatal and os.Exit to main.rand.Shuffle (Fisher-Yates) instead of hand-rolled shuffles._test.go files and use t.TempDir() for file-based tests.Last updated on