Golang E2E 测试利器 rod

2,811 阅读12分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第11天,点击查看活动详情

E2E

业务系统的历史债务往往是 RD 最大的痛点,混乱且没有单测的代码,连重构都没有信心。这件事很多时候就是恶性循环,没有单测,不敢重构,继续在坏掉的代码上添砖加瓦。直到某一天跑路。

但坦率的讲,好的单测是很高的要求,这个时候我们可以利用 End To End 测试来确认重构代码是否符合预期。这样也能给 RD 真实的自信。

Rod 就是 web 页面 E2E 测试的好帮手,今天我们来看看它能做什么。大家只需要安装好 Golang 环境就可以体验了。

Rod

Rod is a high-level driver directly based on DevTools Protocol. It's designed for web automation and scraping for both high-level and low-level use, senior developers can use the low-level packages and functions to easily customize or build up their own version of Rod, the high-level functions are just examples to build a default version of Rod.

go-rod 是一款支持 golang 的 web automation工具,基于DevTools Protocol协议实现,Chrome DevTools Protocol 协议支持与浏览器进行通信,允许使用工具来检测、检查、调试和分析 Chromium、Chrome 和其他基于 Blink 的浏览器。一些需要页面端到端测试的case,使用自动化可以大幅减少手工操作时间。

它具有以下优势:

  • 链式上下文设计;
  • 自动等待网页的元素加载完毕;
  • 对调试很友好,自动跟踪输入,远程监控浏览器;
  • 线程安全;
  • 自动查找,并下载浏览器,参照 launcher
  • 高层次的 helper 方法,如 WaitStable, WaitRequestIdle, HijackRequests, WaitDownload;
  • 两步的 WaitEvent 设计,保障不会丢失 event,参照 goob
  • 正确处理嵌入 iframe;
  • 进程 crash 后不会再有浏览器进程运行,防止泄露,参照 leakless
  • 100% 的测试覆盖率,安全可靠。

作为优秀的开源库,我们同样可以从 Rod 的单测来一窥究竟,看看到底提供了哪些能力。感兴趣的同学可以直接看 example_test.go,以及官方提供的各个案例库

对于对 rod 不熟悉的同学,我们下来一起看一看它的用法,希望能够带大家感受到它的魔力。

这里需要注意,rod 只要求大家有 Golang 的环境,请确保已经安装,不用担心 HTML 不熟悉。

用法入门

用 rod 打开页面截屏需要几行代码?

下面这个程序就是答案:

package main

import "github.com/go-rod/rod"

func main() {
    page := rod.New().MustConnect().MustPage("https://www.wikipedia.org/")
    page.MustWaitLoad().MustScreenshot("a.png")
}

我们新建一个 main.go 文件,复制上面的内容后保存,运行 go run main.go,就会发现在你的项目目录下多了一张图片 a.png,这就是对指定网页的截图:

image.png

rod.New

回到代码,rod.New 创建了一个浏览器对象:

// New creates a controller.
// DefaultDevice to emulate is set to devices.LaptopWithMDPIScreen.Landscape(), it can make the actual view area
// smaller than the browser window on headful mode, you can use NoDefaultDevice to disable it.
func New() *Browser {
	return (&Browser{
		ctx:           context.Background(),
		sleeper:       DefaultSleeper,
		controlURL:    defaults.URL,
		slowMotion:    defaults.Slow,
		trace:         defaults.Trace,
		monitor:       defaults.Monitor,
		logger:        DefaultLogger,
		defaultDevice: devices.LaptopWithMDPIScreen.Landescape(),
		targetsLock:   &sync.Mutex{},
		states:        &sync.Map{},
	}).WithPanic(utils.Panic)
}

事实上我们还可以设置为无痕模式,通过链式的 Incognito 方法即可:

// Incognito creates a new incognito browser
func (b *Browser) Incognito() (*Browser, error) {
	res, err := proto.TargetCreateBrowserContext{}.Call(b)
	if err != nil {
		return nil, err
	}

	incognito := *b
	incognito.BrowserContextID = res.BrowserContextID

	return &incognito, nil
}

MustConnect

拿到了一个 Browser 对象后,通过 MustConnect 来启动并连接到浏览器:

// MustConnect is similar to Browser.Connect
func (b *Browser) MustConnect() *Browser {
	b.e(b.Connect())
	return b
}

// Connect to the browser and start to control it.
// If fails to connect, try to launch a local browser, if local browser not found try to download one.
func (b *Browser) Connect() error

MustPage

连接到浏览器后,我们给出希望打开的地址,通过调用 MustPage 来创建出一个页面对象。对应到下面的 *Page。

// MustPage is similar to Browser.Page.
// The url list will be joined by "/".
func (b *Browser) MustPage(url ...string) *Page {
	p, err := b.Page(proto.TargetCreateTarget{URL: strings.Join(url, "/")})
	b.e(err)
	return p
}

大家可以把 Page 想象成浏览器里的一个 tab。这就是我们这一行做的事情:

page := rod.New().MustConnect().MustPage("https://www.wikipedia.org/")

创建一个 rod 浏览器实例,连接上去,打开指定地址的一个页面 tab。

MustWaitLoad

最后我们调用 Page 对象的 MustWaitLoad 方法,参照下面 WaitLoad 注释,这里会等待 window.onload 事件,也就是等页面完全加载完成。此处返回值还是 Page 对象,继续链式调用。

// MustWaitLoad is similar to Page.WaitLoad
func (p *Page) MustWaitLoad() *Page {
	p.e(p.WaitLoad())
	return p
}

// WaitLoad waits for the `window.onload` event, it returns immediately if the event is already fired.
func (p *Page) WaitLoad() error {
	defer p.tryTrace(TraceTypeWait, "load")()
	_, err := p.Evaluate(evalHelper(js.WaitLoad).ByPromise())
	return err
}

MustScreenshot

最后,我们调用 Page 对象的 MustScreenshot 来截屏:

// MustScreenshot is similar to Screenshot.
// If the toFile is "", it Page.will save output to "tmp/screenshots" folder, time as the file name.
func (p *Page) MustScreenshot(toFile ...string) []byte {
	bin, err := p.Screenshot(false, nil)
	p.e(err)
	p.e(saveFile(saveFileTypeScreenshot, bin, toFile))
	return bin
}

// Screenshot captures the screenshot of current page.
func (p *Page) Screenshot(fullpage bool, req *proto.PageCaptureScreenshot) ([]byte, error) 

// PDF prints page as PDF
func (p *Page) PDF(req *proto.PagePrintToPDF) (*StreamReader, error)

事实上,我们可以看到这里分为两步:

  1. 截屏,拿到图片的数据,一个字节数组;
  2. 将图片数据写入我们指定的文件中。

除了图片,这里还支持 PDF,相当强大。这就是这一行的意义:

page.MustWaitLoad().MustScreenshot("a.png")

进入浏览器

截屏,输出 PDF,或者打印都是很有效的工具。但毕竟只包含结果。有些时候,我们希望直接进入这个浏览器,看看到底发生了什么,rod 当然也是支持的。为了方便调试,我们可以把一些高阶的调试 option 打开,修改上面代码如下:

package main

import (
    "time"

    "github.com/go-rod/rod"
)

func main() {
    page := rod.New().NoDefaultDevice().MustConnect().MustPage("https://www.wikipedia.org/")
    page.MustWindowFullscreen()
    page.MustWaitLoad().MustScreenshot("a.png")
    time.Sleep(time.Hour)
}
  • NoDefaultDevice 和 MustWindowFullscreen 能够将页面最大化,方便我们观看;
  • 这里我们也加上 time.Sleep 让它保持一段时间。

运行命令:

go run . -rod=show

这里的选项 show 意味着在前台展示浏览器的 UI,我们就会看到这样的页面:

image.png

我们可以使用 ctrl+c 来停止调试。

输入和点击

仅仅能够打开网页显然不足以满足我们的诉求,下面我们来看看怎样去操纵页面上的元素。还是以刚才的 wikipedia 页面为例,我们来看一个示例,如何通过代码,让网页自动去搜索,返回搜索结果。

还是原来的代码,我们稍微修改一下启动命令:

go run . -rod=show,devtools

和此前一样,程序会自动打开一个浏览器页面,进入 wikipedia。这时我们需要通过一些手动 inspect 操作,找到输入框的 selector:

image.png

image.png

找到输入框 selector 后,copy 出来,这里我们会得到 #searchInput

修改一下代码:

package main

import (
    "time"

    "github.com/go-rod/rod"
)

func main() {
    browser := rod.New().MustConnect().NoDefaultDevice()
    page := browser.MustPage("https://www.wikipedia.org/").MustWindowFullscreen()

    page.MustElement("#searchInput").MustInput("earth")

    page.MustWaitLoad().MustScreenshot("a.png")
    time.Sleep(time.Hour)
}

注意,我们的修改仅仅是加上了 page.MustElement("#searchInput").MustInput("earth")

  • MustElement 会帮助我们根据选择器,获取到对应的元素,返回了一个 Element。这里自动处理了等待加载的过程,所以我们不需要再次调用 MustWaitLoad 来阻塞。
// MustElement is similar to Page.Element
func (p *Page) MustElement(selector string) *Element {
	el, err := p.Element(selector)
	p.e(err)
	return el
}

// Element retries until an element in the page that matches the CSS selector, then returns
// the matched element.
func (p *Page) Element(selector string) (*Element, error) {
	return p.ElementByJS(evalHelper(js.Element, selector))
}
  • MustInput 是 Element 的方法,输入我们指定的文本。注意,Input 前会保证当前 Element 是可见的,可写入的,和我们自己打开浏览器页面等待加载的过程是一样的。使用空字符串则可以请求空 Element 的文本。
// MustInput is similar to Element.Input
func (el *Element) MustInput(text string) *Element {
	el.e(el.Input(text))
	return el
}

// Input focuses on the element and input text to it.
// Before the action, it will scroll to the element, wait until it's visible, enabled and writable.
// To empty the input you can use something like el.SelectAllText().MustInput("")
func (el *Element) Input(text string) error 

好了,现在我们重新执行 main.go,输入框会按照我们预期,输入了 earth 这个字符串:

image.png

第一步完成,下一步,我们需要点击那个【搜索按钮】,让浏览器自动跳转到搜索结果页。

这里的操作也是类似的,我们就不再赘述。需要大家自行通过 chrome 的 inspect 找到搜索button的 selector,然后修改代码,配合点击即可。

修改后的代码如下:

package main

import (
    "time"

    "github.com/go-rod/rod"
)

func main() {
    browser := rod.New().MustConnect().NoDefaultDevice()
    page := browser.MustPage("https://www.wikipedia.org/").MustWindowFullscreen()

    page.MustElement("#searchInput").MustInput("earth")
    page.MustElement("#search-form > fieldset > button").MustClick()

    page.MustWaitLoad().MustScreenshot("a.png")
    time.Sleep(time.Hour)
}

注意,这里我们多了个新的方法,也是 Element 下面的:

// MustClick is similar to Element.Click
func (el *Element) MustClick() *Element {
	el.e(el.Click(proto.InputMouseButtonLeft, 1))
	return el
}

// Click will press then release the button just like a human.
// Before the action, it will try to scroll to the element, hover the mouse over it,
// wait until the it's interactable and enabled.
func (el *Element) Click(button proto.InputMouseButton, clickCount int) error {
	err := el.Hover()
	if err != nil {
		return err
	}

	err = el.WaitEnabled()
	if err != nil {
		return err
	}

	defer el.tryTrace(TraceTypeInput, string(button)+" click")()

	return el.page.Mouse.Click(button, clickCount)
}

MustClick 会模拟我们人工的操作,尝试滚到我们的 Element 位置,把鼠标挪上去,等待可点击。随后触发点击事件。

这样一来,我们重新运行 main.go,输出的 a.png 就会给我们 earth 的搜索结果了:

image.png

Slow Motion

rod 还提供了 slow motion 和 trace 的功能,方便我们在浏览器上观察发生的动作,让执行慢下来,这样我们才好观察到。

修改启动命令如下:

go run . -rod="show,slow=1s,trace"

这样就启动了 slow motion,所有动作执行前都会等待 1 秒钟。

image.png

我们还可以从 console(控制台)看到发生的动作:

[rod] 2020/11/11 11:11:11 [eval] {"js":"rod.element","params":["#searchInput"]}
[rod] 2020/11/11 11:11:11 [eval] {"js":"rod.visible","this":"input#searchInput"}
[rod] 2020/11/11 11:11:11 [input] scroll into view
[rod] 2020/11/11 11:11:11 [input] input earth
[rod] 2020/11/11 11:11:11 [eval] {"js":"rod.element","params":["#search-form > fieldset > button"]}
[rod] 2020/11/11 11:11:11 [eval] {"js":"rod.visible","this":"button.pure-button.pure-button-primary-progressive"}
[rod] 2020/11/11 11:11:11 [input] scroll into view
[rod] 2020/11/11 11:11:11 [input] left click

除了命令行里通过参数指定,我们还可以直接通过代码实现,slow motion 通过链式API 就可以做到:rod.New().SlowMotion(2 * time.Second)

获取文本

到这里其实就是经典的爬虫做法了,同样我们需要通过 inspect 拿到 selector:

image.png

前面我们见识了MustInput 以及MustClick,获取文本这里则需要用到 MustText

// MustText is similar to Element.Text
func (el *Element) MustText() string {
	s, err := el.Text()
	el.e(err)
	return s
}

// Text that the element displays
func (el *Element) Text() (string, error) {
	str, err := el.Evaluate(evalHelper(js.Text))
	if err != nil {
		return "", err
	}
	return str.Value.String(), nil
}

底层获取到 Element 中的文本,以 string 返回回来,非常简单。我们修改下代码:

package main

import (
    "fmt"

    "github.com/go-rod/rod"
)

func main() {
    page := rod.New().MustConnect().MustPage("https://www.wikipedia.org/")

    page.MustElement("#searchInput").MustInput("earth")
    page.MustElement("#search-form > fieldset > button").MustClick()

    el := page.MustElement("#mw-content-text > div.mw-parser-output > p:nth-child(6)")
    fmt.Println(el.MustText())
}

重新运行项目,这是我们不再看到截屏,而是在命令行看到爬下来的文本:

Earth is the third planet from the Sun and the only astronomical object known to harbor life.
...

获取图片

思路一样,区别是我们需要获取二进制的图片数据了,这里需要用到 Element 的MustResource方法:

// MustResource is similar to Element.Resource
func (el *Element) MustResource() []byte {
	bin, err := el.Resource()
	el.e(err)
	return bin
}

// Resource returns the "src" content of current element. Such as the jpg of <img src="a.jpg">
func (el *Element) Resource() ([]byte, error) {
	src, err := el.Evaluate(evalHelper(js.Resource).ByPromise())
	if err != nil {
		return nil, err
	}

	return el.page.GetResource(src.Value.String())
}

这里会返回一个字节数组,需要我们进行转换。rod 的 util 包也提供了相关方法:

// OutputFile auto creates file if not exists, it will try to detect the data type and
// auto output binary, string or json
func OutputFile(p string, data interface{}) error {
	dir := filepath.Dir(p)
	_ = Mkdir(dir)

	var bin []byte

	switch t := data.(type) {
	case []byte:
		bin = t
	case string:
		bin = []byte(t)
	case io.Reader:
		f, _ := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0664)
		_, err := io.Copy(f, t)
		return err
	default:
		bin = MustToJSONBytes(data)
	}

	return ioutil.WriteFile(p, bin, 0664)
}

这里接收的 data 就可以是个字节数组,p 是文件输出路径。OutputFile 会自行处理创建目录,文件,写入数据的流程。下面我们改下代码看看:

package main

import (
    "github.com/go-rod/rod"
    "github.com/go-rod/rod/lib/utils"
)

func main() {
    page := rod.New().MustConnect().MustPage("https://www.wikipedia.org/")

    page.MustElement("#searchInput").MustInput("earth")
    page.MustElement("#search-form > fieldset > button").MustClick()

    el := page.MustElement("#mw-content-text > div.mw-parser-output > table.infobox > tbody > tr:nth-child(1) > td > a > img")
    _ = utils.OutputFile("b.png", el.MustResource())
}

重新运行后,我们的 b.png 文件就包含了要爬的图片:

image.png

E2E 示例

这一节我们来看看官方给出的 E2E 示例。真实的业务场景远比这个复杂,但本质内核都是一样的,大家可以结合相关 API 设计自己的 E2E 流程。

ahfarmer.github.io/calculator/ 是一个经典的在线计算器,大家可以自己打开看一看,页面非常简单,模拟大家手机的计算器 app:

image.png

假定我们是这个计算器的开发者,希望做端到端测试,就可以借助 rod 的能力,直接在网页上操作加减法,看看最终结果是否符合预期。

为此,我们还需要借助一些测试框架的断言能力,比较结果。这里官方是用了作者自己的测试库:"github.com/ysmood/got",大家有需求的话其实可以替换成自己常用的。

  • 第一步,我们需要先创建出一个 Browser 对象,毕竟 E2E 不可能只测几个单独的case,也需要支持并发。这里可以使用一个共用的 Browser 对象。新建一个 setup_test.go,填充以下内容:
// This is the setup file for this test suite.

package main

import (
	"testing"

	"github.com/go-rod/rod"
	"github.com/ysmood/got"
)

// test context
type G struct {
	got.G

	browser *rod.Browser
}

// setup for tests
var setup = func() func(t *testing.T) G {
	browser := rod.New().MustConnect()

	return func(t *testing.T) G {
		t.Parallel() // run each test concurrently

		return G{got.New(t), browser}
	}
}()

// a helper function to create an incognito page
func (g G) page(url string) *rod.Page {
	page := g.browser.MustIncognito().MustPage(url)
	g.Cleanup(page.MustClose)
	return page
}

这里的 page 也是默认开启无痕模式,打开指定的 url,利用了 ysmood/got 的 Cleanup 能力随后进行清理,本质是个 helper 函数。

  • 第二步,开始校验业务逻辑,新建 calculator_test.go,填充以下内容:
// Package main ...
package main

import "testing"

// test case: 1 + 2 = 3
func TestAdd(t *testing.T) {
	g := setup(t)

	p := g.page("https://ahfarmer.github.io/calculator")

	p.MustElementR("button", "1").MustClick()
	p.MustElementR("button", `^\+$`).MustClick()
	p.MustElementR("button", "2").MustClick()
	p.MustElementR("button", "=").MustClick()

	// assert the result with t.Eq
	g.Eq(p.MustElement(".component-display").MustText(), "3")
}

// test case: 2 * 3 = 6
func TestMultiple(t *testing.T) {
	g := setup(t)

	p := g.page("https://ahfarmer.github.io/calculator")

	// use for-loop to click each button
	for _, regex := range []string{"2", "x", "3", "="} {
		p.MustElementR("button", regex).MustClick()
	}

	g.Eq(p.MustElement(".component-display").MustText(), "6")
}

这里只是示例,case 比较简单。大家可以自行调整一下试试。

  • 第一个case我们需要校验加法是否正常我们打开计算器网页,通过 MustElementR 获取到指定元素,触发点击。
// MustElementR is similar to Page.ElementR
func (p *Page) MustElementR(selector, jsRegex string) *Element {
	el, err := p.ElementR(selector, jsRegex)
	p.e(err)
	return el
}

// ElementR retries until an element in the page that matches the css selector and it's text matches the jsRegex,
// then returns the matched element.
func (p *Page) ElementR(selector, jsRegex string) (*Element, error) {
	return p.ElementByJS(evalHelper(js.ElementR, selector, jsRegex))
}

这里的 MustElementR 补充了正则能力,毕竟有些元素单独靠 selector 无法定位,这里是增强的能力。

触发元素点击后,通过断言,判断文本和指定的结果是否匹配即可。

  • 第二个 case 也是类似的场景,大家可以由此体会一下 MustElementR 的用法。

执行测试case和平常的 Golang 单测没有区别,直接 go test 命令即可。

小结

这里我们只是拿出来 rod 常见的一些用法,它可以打印,可以转图片,截屏,导出 pdf。可以模拟人为的浏览器操作,整体功能还是非常强大的,建议大家自己动手试一下。官方的 guide 也是很不错的参考资料。