Automating web browser is pretty common and it’s nothing new. Selenium is de facto industry standard and there are libraries for various programming languages with most common being Java and Ruby. What’s less common is writing Selenium automated tests using Golang, so this blog post will be exactly about that. Most of the code is straightforward and heavily commented, so I will not go much into details in post. If you have any questions, feel free to reach me in comments.
Dependencies
Selenium library from Tebeka is our main dependency which will allow us to run web browser and interact with it. To get this dependency, just run:
go get -t -d github.com/tebeka/selenium
Next module we will install is Ginkgo, a library allowing for expressive Behavior-Driven Development style tests. Fetch it with:
go get -u github.com/onsi/ginkgo/ginkgo
Finally, for matching results, we will use Gomega, library typically paired with Ginkgo. Get it with:
go get github.com/onsi/gomega/...
We will also need some binaries to run tests:
- Selenium server (selenium-server-standalone-xxx.jar file)
- Chromedriver (chromedriver executable file for running Chrome browser)
- Geckodriver (geckodriver executable file for running Firefox browser)
You can download, extract and install them manually, or use this script from library authors as a guide to automate this process. Notice that you would at least want to update browsers and drivers versions.
Application that will be under test is simple React Gin Blog, developed in this guide. We will configure whole test development environment and implement two basic tests – success and failure scenario.
Test development
Let’s start by creating some root directory for our project. I will name it rgb_go_selenium
. Inside of it, we create new file configuration.go
which will hold code for various test configuration data:
package rgb_go_selenium
import (
"fmt"
"time"
"github.com/rs/zerolog/log"
"github.com/tebeka/selenium"
"github.com/tebeka/selenium/firefox"
)
// Env represents environment domain where tests will be run.
type Env string
// Browser represents browser type in which tests will be run.
type Browser string
const (
// Env keys.
DevEnv Env = "dev"
UATEnv Env = "uat"
PreprodEnv Env = "preprod"
// Browser types.
Chrome Browser = "chrome"
Firefox Browser = "firefox"
// Paths to necessarry binaries. Chenge these to match to binary locations on your machine.
seleniumPath = "/usr/share/java/selenium-server.jar"
geckoDriverPath = "/usr/bin/geckodriver"
chromeDriverPath = "/usr/bin/chromedriver"
// Default timeout for WebDriver.
DefTimeout = 5 * time.Second
)
var urlMap = map[Env]string{
DevEnv: "localhost:8080",
UATEnv: "uat.rgb.com",
PreprodEnv: "preprod.rgb.com",
}
// Conf represents configuration data.
type Conf struct {
Browser Browser
Env Env
Headless bool
DisplayAddress string
Port int
Width int
Height int
}
var (
conf Conf
caps selenium.Capabilities
)
// GetConf returns current set configuration.
func GetConf() Conf { return conf }
// SetCaps defines Selenium capabailities based on passed configuration.
func SetCaps(cnf Conf) {
switch cnf.Browser {
case Firefox:
setFirefoxCaps(cnf)
case Chrome:
setChromeCaps(cnf)
default:
log.Panic().Str("Browser", string(cnf.Browser)).Msg("Invalid Browser type.")
}
}
// GetCaps returns currently set Selenium capabilities.
func GetCaps() selenium.Capabilities { return caps }
func setFirefoxCaps(cnf Conf) {
args := []string{
fmt.Sprintf("--width=%d", cnf.Width),
fmt.Sprintf("--height=%d", cnf.Height),
}
if cnf.Headless {
args = append(args, "-headless")
}
firefoxCaps := firefox.Capabilities{
Args: args,
}
caps = selenium.Capabilities{
"browserName": "firefox",
firefox.CapabilitiesKey: firefoxCaps,
}
}
func setChromeCaps(cnf Conf) {
args := []string{
fmt.Sprintf("--window-size=%d,%d", cnf.Width, cnf.Height),
"--ignore-certificate-errors",
"--disable-extensions",
"--no-sandbox",
"--disable-dev-shm-usage",
}
if cnf.Headless {
args = append(args, "--headless", "--disable-gpu")
}
chromeCaps := map[string]interface{}{
"excludeSwitches": [1]string{"enable-automation"},
"args": args,
}
caps = selenium.Capabilities{
"browserName": "chrome",
"chromeOptions": chromeCaps,
}
}
As you can see, this file holds configuration for supported environments, browser type, paths to binaries needed to run tests, etc. Selenium capabilities for supported browsers are also defined here. If you want to know more about capabilities, check Chromedriver doc and Geckodriver doc.
Tests will be started from terminal, so it will be useful to add some CLI arguments that can be passed to tests. For that let’s create new file under project root directory, named cli.go
:
package rgb_go_selenium
import (
"flag"
"fmt"
"net/url"
"os"
"strconv"
)
// Define CLI arguments with name, default value and description.
var (
browser = flag.String("browser", "chrome", `Browser to run tests in. Possible values are "chrome" and "firefox"`)
env = flag.String("env", "dev", `Sets run environment. Possible values are "dev", "uat" and "preprod"`)
headless = flag.String("headless", "false", `Sets headless mode. Possible values are "false" and "true"`)
displayAddress = flag.String("displayAddress", "", `X server address.`)
port = flag.Int("port", 4444, `Selenium server port. Must be a number between 1024-65535.`)
width = flag.Int("width", 1920, `Display width.`)
height = flag.Int("height", 1080, `Display height.`)
)
func usage() {
fmt.Print(`This program runs RGB tests.
Usage:
go test [arguments]
Supported arguments:
`)
flag.PrintDefaults()
}
// Parses passed arguments, sets conf and caps global variables.
func ParseArgs() {
// Set function to be called if parsing fails.
flag.Usage = usage
// Parse CLI arguments.
flag.Parse()
// Print usage text and exit if:
// - browser is neither "chrome" or "firefox",
// - env is neither "dev", "uat" or "preprod",
// - headless is neither "false" or "true",
// - displayAddress is not valid IP address,
// - port is not a number between 1024-65535
isHeadless, err := strconv.ParseBool(*headless)
if !(validBrowserArg() && validEnvArg() && err == nil && validDisplayArg() && (*port >= 1024 && *port <= 65535)) {
usage()
os.Exit(2)
}
// Set conf global variable.
conf = Conf{
Browser: Browser(*browser),
Env: Env(*env),
Headless: isHeadless,
DisplayAddress: *displayAddress,
Port: *port,
Width: *width,
Height: *height,
}
// Set caps global variable.
SetCaps(conf)
}
func validBrowserArg() bool {
return (*browser) == string(Chrome) || *browser == string(Firefox)
}
func validEnvArg() bool {
return *env == string(DevEnv) || *env == string(UATEnv) || *env == string(PreprodEnv)
}
func validDisplayArg() bool {
_, err := url.Parse(*displayAddress)
return err == nil
}
Available arguments with default values are defined in this file. Argument parsing logic is also implemented here. Tests are started using ginkgo
command and custom arguments can be passed after "--"
part. For example, run user/
tests with default arguments with:
ginkgo user/
And to run user/
tests in headless mode, use:
ginkgo user/ -- -headless=true
Next we will add code to deal with all those binaries, start Selenium server and connect to X server. For that we will add new file, driver.go
:
package rgb_go_selenium
import (
"os"
"github.com/BurntSushi/xgb"
"github.com/BurntSushi/xgbutil"
"github.com/rs/zerolog/log"
"github.com/tebeka/selenium"
)
const (
SeleniumLogPath = "/home/matija/go/src/github.com/matijakrajnik/rgb_go_selenium/selenium.log"
XGBLogPath = "/home/matija/go/src/github.com/matijakrajnik/rgb_go_selenium/xgb.log"
)
// StartSelenium starts Selenium server. Log output is saved to SeleniumLogPath file.
func StartSelenium() (*selenium.Service, error) {
logFile, err := os.OpenFile(SeleniumLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Error().Err(err).Str("SeleniumLogPath", SeleniumLogPath).Msg("Error while opening Selenium log file.")
return nil, err
}
opts := []selenium.ServiceOption{
selenium.GeckoDriver(geckoDriverPath), // Specify the path to GeckoDriver in order to use Firefox.
selenium.ChromeDriver(chromeDriverPath), // Specify the path to ChromeDriver in order to use Chrome.
selenium.Output(logFile), // Output debug information to selenium.log file.
}
service, err := selenium.NewSeleniumService(seleniumPath, conf.Port, opts...)
if err != nil {
log.Error().Err(err).Msg("Can't start Selenium server.")
}
return service, err
}
// ConnectToDisplay creates new frame buffer and connects to X server.
func ConnectToDisplay() (*xgbutil.XUtil, error) {
frameBuffer, err := selenium.NewFrameBuffer()
if err != nil {
log.Error().Err(err).Msg("Can't create frame buffer.")
return nil, err
}
logFile, err := os.OpenFile(XGBLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Error().Err(err).Str("XGBLogPath", XGBLogPath).Msg("Error while opening XGB log file.")
return nil, err
}
xgb.Logger.SetOutput(logFile)
display, err := xgbutil.NewConnDisplay(conf.DisplayAddress + ":" + frameBuffer.Display)
if err != nil {
log.Error().Err(err).Msg("Can't connect to display.")
}
return display, err
}
Often in automated tests we will use few lines of similar code over and over. In this case, good thing to do is to move that code to helper functions. Also, Ginkgo and Gomega by default are using dot imports, but we can avoid that as explained here. Our helper functions and Ginkgo/Gomega variables will be defined in helpers.go
:
package rgb_go_selenium
import (
"io/ioutil"
"time"
"github.com/onsi/ginkgo"
"github.com/onsi/gomega"
"github.com/rs/zerolog/log"
"github.com/tebeka/selenium"
)
// Avoid Ginkgo and Gomega dot imports by assigning needed functions to variables.
var (
Fail = ginkgo.Fail
RunSpecs = ginkgo.RunSpecs
Describe = ginkgo.Describe
BeforeEach = ginkgo.BeforeEach
AfterEach = ginkgo.AfterEach
It = ginkgo.It
CurrentTest = ginkgo.CurrentGinkgoTestDescription
RegisterFailHandler = gomega.RegisterFailHandler
Expect = gomega.Expect
HaveOccurred = gomega.HaveOccurred
BeZero = gomega.BeZero
)
// URL returns full path for passed environment value.
func URL(env Env) string { return "http://" + urlMap[env] }
// TakeScreenshot saves screenshot of passed WebDriver into file with passed test name.
func TakeScreenshot(wd selenium.WebDriver, testName string) {
bytes, err := wd.Screenshot()
if err != nil {
log.Panic().Err(err).Msg("Can't take a screenshot.")
}
ioutil.WriteFile(testName+".jpg", bytes, 0644)
}
// ErrCheck checks if error occurred.
func ErrCheck(err error) {
Expect(err).ToNot(HaveOccurred())
}
// MustFindElement returns element or fails if element is not found.
func MustFindElement(wd selenium.WebDriver, by, value string) selenium.WebElement {
element, err := wd.FindElement(by, value)
ErrCheck(err)
return element
}
// MustNotFindElement returns fails if element is found.
func MustNotFindElement(wd selenium.WebDriver, by, value string) {
wd.SetImplicitWaitTimeout(time.Second)
defer wd.SetImplicitWaitTimeout(DefTimeout)
element, err := wd.FindElement(by, value)
Expect(element).To(BeZero())
Expect(err).To(HaveOccurred())
}
// MustWaitWithTimeout wait for passed selenium.Condition a given amount of time and checks for returned error value.
func MustWaitWithTimeout(wd selenium.WebDriver, condition selenium.Condition, timeout time.Duration) {
ErrCheck(wd.WaitWithTimeout(condition, timeout))
}
With this preparation done, we are ready to write our first test. We will start by adding tests about user features, so let’s create directory user
and file user/user_test.go
:
package user
import (
"fmt"
"testing"
"time"
. "github.com/matijakrajnik/rgb_go_selenium"
"github.com/BurntSushi/xgbutil"
"github.com/rs/zerolog/log"
"github.com/tebeka/selenium"
)
// User test suite.
func TestUser(t *testing.T) {
ParseArgs()
RegisterFailHandler(Fail)
RunSpecs(t, "User")
}
var _ = Describe("User", func() {
var (
service *selenium.Service
display *xgbutil.XUtil
wd selenium.WebDriver
username = fmt.Sprintf("batman_%v", time.Now().Unix())
password = "secret123"
)
BeforeEach(func() {
var err error
service, err = StartSelenium()
Expect(service).ToNot(BeZero())
ErrCheck(err)
display, err = ConnectToDisplay()
Expect(display).ToNot(BeZero())
ErrCheck(err)
wd, err = selenium.NewRemote(GetCaps(), fmt.Sprintf("http://localhost:%d/wd/hub", GetConf().Port))
ErrCheck(err)
Expect(wd).ToNot(BeZero())
ErrCheck(wd.SetImplicitWaitTimeout(DefTimeout))
ErrCheck(wd.Get(URL(GetConf().Env)))
})
AfterEach(func() {
TakeScreenshot(wd, CurrentTest().TestText)
err := wd.Quit()
ErrCheck(err)
display.Conn().Close()
if err := service.Stop(); err != nil {
log.Error().Err(err).Msg("Error while stoping Selenium server.")
}
})
It("can create new account", func() {
loginLink := MustFindElement(wd, selenium.ByLinkText, "LOGIN")
ErrCheck(loginLink.Click())
newAccountLink := MustFindElement(wd, selenium.ByCSSSelector, ".btn-link")
ErrCheck(newAccountLink.Click())
usernameInput := MustFindElement(wd, selenium.ByID, "username")
ErrCheck(usernameInput.SendKeys(username))
passwordInput := MustFindElement(wd, selenium.ByID, "password")
ErrCheck(passwordInput.SendKeys(password))
submitButton := MustFindElement(wd, selenium.ByCSSSelector, ".btn-success")
ErrCheck(submitButton.Click())
MustWaitWithTimeout(wd, func(wd selenium.WebDriver) (bool, error) {
header := MustFindElement(wd, selenium.ByTagName, "h1")
text, err := header.Text()
return text == "Welcome to React Gin Blog!", err
}, 5*time.Second)
logoutLink := MustFindElement(wd, selenium.ByCSSSelector, ".btn-dark")
Expect(logoutLink).ToNot(BeZero())
})
})
We start by creating TestUser
function where we parse passed CLI arguments, register fail handler, which is here used to connect Ginkgo and Gomega, and then we run spec named User
. For organizing specs we are using Ginkgo functions Describe and It. We also defined functions to be executed before and after each test case using BeforeEach and AfterEach handlers. App that we are testing doesn’t have many IDs for HTML elements (which is also often case in real production applications), so I had to be creative with locating elements. For that CSS selectors were mostly used when there was no ID or link text available.
Next example will be fail scenario, when user tries to create account using too short username. To do that, add next lines of code for second It just below first It:
It("can't create new account if username is too short", func() {
loginLink := MustFindElement(wd, selenium.ByLinkText, "LOGIN")
ErrCheck(loginLink.Click())
newAccountLink := MustFindElement(wd, selenium.ByCSSSelector, ".btn-link")
ErrCheck(newAccountLink.Click())
usernameInput := MustFindElement(wd, selenium.ByID, "username")
ErrCheck(usernameInput.SendKeys("bat"))
passwordInput := MustFindElement(wd, selenium.ByID, "password")
ErrCheck(passwordInput.SendKeys(password))
submitButton := MustFindElement(wd, selenium.ByCSSSelector, ".btn-success")
ErrCheck(submitButton.Click())
MustWaitWithTimeout(wd, func(wd selenium.WebDriver) (bool, error) {
errorMsg := MustFindElement(wd, selenium.ByCSSSelector, ".alert-danger")
text, err := errorMsg.Text()
return text == "Username must be longer than or equal 5 characters.", err
}, 5*time.Second)
MustNotFindElement(wd, selenium.ByCSSSelector, ".btn-dark")
})
Now it also becomes obvious why we need helper functions. As you can see we are using them extensively through both test cases, so without them we would have to write much more lines of very repetitive code.
Tests are now ready to run. You can find whole code on this GitHub repo.
Run user tests with default values:

Run user tests with default values in headless mode:

Run user tests in headless mode using Firefox browser:
