由于aetest非常慢,因此想办法解决的问题

aetest是什么

以下是GAE/Go用于本地测试的包。
虽然GAE已经转变为第二代,并且将来不太可能被新用户使用,但我希望将过去的努力记录下来作为纪念。

以下是GAE的测试样例代码。aetest会在测试执行时在本地启动一个具有GAE特定功能的服务器,通过使用包含该服务器信息的上下文,可以进行测试。

在示例代码中,使用了GAE的Memcache功能。

package main

import (
    "google.golang.org/appengine"
    "google.golang.org/appengine/aetest"
    "google.golang.org/appengine/memcache"
    "testing"
)

func TestAeTest(t *testing.T) {
    inst, err := aetest.NewInstance(nil)
    if err != nil {
        t.Fatal(err)
    }
    defer inst.Close()

    req, err := inst.NewRequest("GET", "/", nil)

    ctx := appengine.NewContext(req)

    it := &memcache.Item{
        Key:   "some-key",
        Value: []byte("some-value"),
    }
    err = memcache.Set(ctx, it)
    if err != nil {
        t.Fatalf("Set err: %v", err)
    }
    it, err = memcache.Get(ctx, "some-key")
    if err != nil {
        t.Fatalf("Get err: %v; want no error", err)
    }
    if g, w := string(it.Value), "some-value" ; g != w {
        t.Errorf("retrieved Item.Value = %q, want %q", g, w)
    }
}

在aetest.NewInstance()中,所做的是将简单的GAE应用程序设置创建在临时区域中。

设置在内部定义,并以最少的设置作为GAE/Go应用程序启动。


const appYAMLTemplate = `
application: %s
version: 1
runtime: go111

handlers:
- url: /.*
  script: _go_app
`

const appSource = `
package main
import "google.golang.org/appengine"
func main() { appengine.Main() }
`

在这里,GAE的开发服务器命令已经设置好,并且通过子进程启动了类似于`dev_appserver.py /var/folders/1w/9n3yj1g95g12zgjhm9xxcypc0000gp/T/tmp4C_Mm8appengine-go-bin/app.yml`的命令。

开发服务器启动后,会在标准输出中显示API等的URL。我们采用正则表达式将其提取出来,并向其中发送datastore等命令。这是一个相当狂野的实现方式。


INFO     2019-10-10 06:39:59,912 devappserver2.py:278] Skipping SDK update check.
WARNING  2019-10-10 06:39:59,912 devappserver2.py:294] DEFAULT_VERSION_HOSTNAME will not be set correctly with --port=0
INFO     2019-10-10 06:40:00,098 datastore_emulator.py:155] Starting Cloud Datastore emulator at: http://localhost:21206
WARNING  2019-10-10 06:40:00,100 simple_search_stub.py:1196] Could not read search indexes from /var/folders/1w/9n3yj1g95g12zgjhm9xxcypc0000gp/T/appengine.testapp.hoshina/search_indexes
INFO     2019-10-10 06:40:01,231 datastore_emulator.py:161] Cloud Datastore emulator responded after 1.132465 seconds
INFO     2019-10-10 06:40:01,232 api_server.py:275] Starting API server at: http://localhost:64020
INFO     2019-10-10 06:40:01,237 api_server.py:265] Starting gRPC API server at: http://localhost:64022
INFO     2019-10-10 06:40:01,312 dispatcher.py:256] Starting module "default" running at: http://localhost:64023
INFO     2019-10-10 06:40:01,316 admin_server.py:150] Starting admin server at: http://localhost:64025
INFO     2019-10-10 06:40:03,328 stub_util.py:357] Applying all pending transactions and saving the datastore
INFO     2019-10-10 06:40:03,328 stub_util.py:360] Saving search indexes
INFO     2019-10-10 06:40:04,252 instance.py:294] Instance PID: 57474
var apiServerAddrRE = regexp.MustCompile(`Starting API server at: (\S+)`)
var adminServerAddrRE = regexp.MustCompile(`Starting admin server at: (\S+)`)

最初我误解了,aetest并不会为我们启动目标应用程序,它只是提供了使用appengineAPI所需的最基本资源。
所以它并不能模拟应用程序的路由,因此如果我们创建了一个模拟了实际应用程序的请求并通过http.Client的Do(req)方法进行测试,是无法正常工作的,需要注意。

aetest.NewInstance()这个函数表现出来的行为是每次都会启动一个子进程,所以速度相当慢。
在下面的测试中,每个测试都要花费大约5秒钟的时间,其中大部分时间都用于启动dev_appserver.py。

package main

import (
    "google.golang.org/appengine"
    "google.golang.org/appengine/aetest"
    "testing"
)

func TestAppID(t *testing.T) {
    ctx, done, err := aetest.NewContext()
    if err != nil {
        t.Fatal(err)
    }
    defer done()
    val := appengine.AppID(ctx)
    if val != "testapp" {
        t.Fatalf("got: %v\nwant: %v", val, "testapp")
    }
}

func TestDefaultVersionHostname(t *testing.T) {
    ctx, done, err := aetest.NewContext()
    if err != nil {
        t.Fatal(err)
    }
    defer done()
    val := appengine.DefaultVersionHostname(ctx)
    if val != "" {
        t.Fatalf("got: %v\nwant: %v", val, "")
    }
}

基本上,实例应该是可重复使用的,因此可以将实例创建为包变量并保持,以便在需要时返回先前创建的实例(如果设置相同),从而避免每次启动。

package main

import (
    "context"
    "fmt"
    "google.golang.org/appengine"
    "google.golang.org/appengine/aetest"
    "os"
    "testing"
)

func TestAppID(t *testing.T) {
    ctx, done := NewContext(nil)
    defer done()
    val := appengine.AppID(ctx)
    if val != "testapp" {
        t.Fatalf("got: %v\nwant: %v", val, "testapp")
    }
}

func TestDefaultVersionHostname(t *testing.T) {
    ctx, done := NewContext(nil)
    defer done()
    val := appengine.DefaultVersionHostname(ctx)
    if val != "" {
        t.Fatalf("got: %v\nwant: %v", val, "")
    }
}

var instances map[string]aetest.Instance

func NewContext(opt *aetest.Options) (context.Context, func()) {
    var err error
    key := fmt.Sprintf("%#v", opt)
    //設定が異なる場合違うインスタンスを作成する
    if _, ok := instances[key]; !ok {
        instances[key], err = aetest.NewInstance(opt)
        if err != nil {
            panic(err)
        }
    }

    req, err := instances[key].NewRequest("GET", "/", nil)
    if err != nil {
        panic(err)
    }
    ctx := appengine.NewContext(req)
    return ctx, func() {
    //なにもしない
    }
}

func TestMain(m *testing.M) {
    instances = map[string]aetest.Instance{}
  //パッケージ内のテストを終了
    status := m.Run()

    //goroutineがインスタンスの終了を待っているので最後に終了する
    for _, i := range instances {
        i.Close()
    }
    os.Exit(status)
}

需要注意的是,由于aetest.Instance在等待子进程继续运行的goroutine中,所以只要有一个实例启动,就会继续等待goroutine结束,导致无法结束测试进程。

為了結束測試,必須確保在最後終止實例。

然而,这里有一个问题。如果在测试中出现 panic,那么由于实例没有在 m.Run() 之后退出,测试将无法结束。


func TestPanic(t *testing.T) {
    panic("from TestPanic")
}

所以,如果发生恐慌(panic),可以通过调用recover()来捕获它并终止实例,所以我认为在TestMain中使用defer就可以了,于是我对TestMain进行了如下修改。


func TestMain(m *testing.M) {
    status := 0

    defer func() {
        for _, i := range instances {
            i.Close()
        }
        os.Exit(status)
    }()
    instances = map[string]aetest.Instance{}
    status = m.Run()

    //goroutineがインスタンスの終了を待っているので最後に終了する
    for _, i := range instances {
        i.Close()
    }
}

然而,这个变更毫无意义。

通常情况下,当测试发生panic时,尝试输出测试结果并立即终止。但是,如果在testing以外的goroutine中,通道处于等待输入的状态等停止状态时,没有发送完整的测试完成信号,测试进程就无法终止。

即使结束信号返回了,因为每个测试本身就在 goroutine 中执行,并且无法跨越 goroutine 进行 recover,所以这段代码是多余的且没有意义的。

最后该怎么办呢?需要使goroutine不必等待服务器的结束,或者需要使panic时也能恢复控制。

如果选择前者的话,可以在TestMain中创建所有服务器的各种情况,就可以解决问题。但是,如果设置有很多种情况,可能会很困难。
另外,即使发生panic,也可以通过明确地返回testing的控制来向父goroutine传达结束的消息。

func TearDown(t *testing.T) {
    if err := recover(); err != nil {
        debug.PrintStack() //スタックトレースが出ないので自分で吐いている
        t.Fatal(err)
    }
}

func TestPanic(t *testing.T) {
    defer TearDown(t)
    panic("from TestPanic")
}

虽然是一个矮小的话题,但如果测试依赖于外部进程并试图在测试执行时管理其状态,可能会陷入类似的情况。(例如,在测试执行期间想以不同的模式启动数据库)
在那种情况下,如果试图通过TestMain或类似的方式进行通用的前处理和后处理,可能会陷入异常的困境。

广告
将在 10 秒后关闭
bannerAds