I’ve started working on client tests, which have been getting very neglected compared to the server. I figured I’d write some quick notes on what I’ve done so far before continuing, before I forget.

The first thing I’m choosing to focus on testing is interaction with the server. I’m already testing my API on the server side, but I also want to make sure I’m sending correct requests and handling responses correctly from the client. So we’re not really talking about unit tests here - these tests will communicate with the server and work against an actual test database. To do this I decided to have the server support a “test” mode.

Server test mode

Starting the server in normal mode looks like this:

snaillifesrv serve

Starting the server in test mode looks like this:

snaillifesrv serve test

The extra test parameter is handled in the serve command:

func startServer(args []string) {
	if len(args) > 0 {
		if args[0] == "test" {
			prepTestMode()
		}
	}
	fmt.Println("Starting SnailLife server.")
	restapi.StartServer(ServerPort)
}

func prepTestMode() {
	fmt.Println("Starting in test mode")
	os.Setenv("SNAILLIFE_TESTMODE", "true")
	infrastructure.ReloadDb()
	tu := tests.NewTestUtil()
	tu.PrepMySqlUsersTable(env.App.Configuration.DBconfig.DBName, "")
	c := make(chan os.Signal, 1)
	signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
	go func() {
		fmt.Println("Waiting for test srv termination")
		<-c
		fmt.Println("Running test srv cleanup")
		tu.DeleteTestDB(env.App.Configuration.DBconfig.DBName)
		msg := fmt.Sprintf("Deleting test db %s", env.App.Configuration.DBconfig.DBName)
		fmt.Println(msg)
		os.Exit(1)
	}()
}

in prepTestMode() we set a special environment variables (SNAILLIFE_TESTMODE) to true and reload the database configuration:

func ReloadDb() {
	prepDb(env.App)
}

func prepDb(app env.Application) env.Application {
	dbc := &app.Configuration.DBconfig
	switch dbc.DBType {
	case "mysql":
		inTestMode := os.Getenv("SNAILLIFE_TESTMODE")
		if inTestMode == "true" {
			dbc.DBName += "_test"
		}
		app.Database = &mysql.MySql{}
		app.Database.Configure(dbc.DBUser, dbc.DBPass, dbc.DBHost, dbc.DBName)
		break
	}
	app.Database.Connect()
	return app
}

in prepDb(), if SNAILLIFE_TESTMODE is true, we modify the database name configuration to append _test to it (because we don’t want to work against the live db), and then configure that database.

Then, in prepTestMode(), we prepare our tables - right now this is just the users table, since that’s the first thing I’m focusing on testing. This creates the table and seeds it with whatever data I’ll need for the test.

Finally, we set up a channel that will be notified when we get a SIGINT or SIGTERM signal, and start a goroutine that will wait for a message to this channel and then run some cleanup - like deleting the test database before exit.

The test

On the client side we make a commands_test.go file. Most of my tests are inside their own tests package under each package I’m testing, but this one will need to access some unexported methods so I’m keeping it in the commands package. I’ve also added a util_test.go where we’ll do all the server starting/shutting down stuff.

in commands_test we define a couple of consts which we’ll use to tell when the server process has started or been killed:

const (
	procStartDone = iota
	procStartFailed
)

Then we modify TestMain to start the server before running the tests and stop it afterwards. I haven’t really had the need to start processes through goroutines before this (or heck use goroutines at all), so I was learning about goroutines/channels/etc while implementing this. It’ll likely need to be revised as I learn more. I’m adding comments inline:

TestMain()

func TestMain(m *testing.M) {
	fmt.Println("Starting command tests")
	var mainWg sync.WaitGroup // We will use this WaitGroup to wait for the server goroutine to be complete
	infrastructure.Init()
	ctx, cancel := context.WithCancel(context.Background()) // Use a context to cancel the goroutine when needed

    procStart := make(chan int) // Make a channel to detect server process start
	go startServer(ctx, &mainWg, procStart)

	ret := 1

	attemptsLeft := 10 // We'll give ourselves 10 atempts to check for process start
	var ps int

	WAIT:
	for attemptsLeft > 0 {
		fmt.Printf("\nWaiting for server start. Attempts left: %d", attemptsLeft)
		select {
		case ps = <-procStart:
			break WAIT // If the process has started, break out of the loop
		default:
			attemptsLeft-- // Otherwise decrease remaining attempt count and sleep for a second.
			time.Sleep(time.Second * 1)
		}

	}

	// Once we've got a process start status through the channel, run the tests if the process has successfully started
	if ps == procStartDone {
		ret = m.Run()
        // After we're done, add 1 to the wait group and call cancel.
		mainWg.Add(1)
		fmt.Println("\nTests done, calling cancel")
		cancel()
		mainWg.Wait() // Wait for waitgroup to be done before exiting. 
	}

	os.Exit(ret)
}

startServer()

Comments inline:

func startServer(ctx context.Context, wg *sync.WaitGroup, procStart chan int) {
	mutex = &sync.RWMutex{} // this is a package-wide var
	subproc = exec.Command("snaillifesrv", "serve", "test") 
	subproc.Stdout = os.Stdout
	fmt.Println("Running subproc")
	err := subproc.Start() // Start the server in test mode
	fmt.Println("Ran subproc")
	if err != nil {
        // If there's an error just send a failed message to TestMain
		fmt.Printf("\nError starting server: %s", err)
		procStart <- procStartFailed
	} else {
        // Otherwise start checking for procss start and kill. 
		checkProcessStart(ctx, subproc, wg, procStart, 10)
		checkProcessKill(ctx, subproc, wg)
	}
}

func checkProcessStart(ctx context.Context, subproc *exec.Cmd, wg *sync.WaitGroup, psc chan int, attemptsLeft int) {
	for attemptsLeft > 0 {
		fmt.Printf("\ncheckProcessStart attempt %d", attemptsLeft)
		mutex.RLock()
		defer mutex.RUnlock()
		if subproc.Process == nil && attemptsLeft == 0 {
			psc <- procStartFailed
			return
		} else if subproc.Process != nil {
			fmt.Println("\nHouston, we have a process")
			psc <- procStartDone
			return
		}
		attemptsLeft--
		time.Sleep(time.Second * 1)
	}
}

func checkProcessKill(ctx context.Context, subproc *exec.Cmd, wg *sync.WaitGroup) {
	time.AfterFunc(1 * time.Second, func() {
		fmt.Println("Checking for kill")
		select {
		case <-ctx.Done():
			fmt.Println("Killing server process")
			mutex.Lock()
			if subproc != nil && subproc.Process != nil {
				subproc.Process.Signal(os.Interrupt)
			} else {
				fmt.Println("Have we already killed?")
			}
			mutex.Unlock()
			wg.Done() // Tell the waitgroup we're done! This is where TestMain will go on to exit.
		default:
			time.Sleep(time.Second * 1)
			checkProcessKill(ctx, subproc, wg)
		}
	})
}

Anyway, that’s about it for now. I’ve got the login tests running and am now working on the registration tests, which are actually already exposing stuff I have been too lazy to take care of - like email format validation…so now I’m fixing that.