Selenium WebDriver test automation using Golang

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 (
	"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,
	}
}

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:

Ginkgo run with default values

Run user tests with default values in headless mode:

Ginkgo run in headless mode

Run user tests in headless mode using Firefox browser:

Ginkgo run using Firefox in headless mode

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: