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.