测试开发之路 mock server 实践

孙高飞 · 2020年12月02日 · 最后由 Test44 回复于 2021年01月04日 · 3335 次阅读
本帖已被设为精华帖!

mock server 的一般玩法

  • mock server 的目的是一定程度上隔离待测系统,将依赖的其他服务 mock 掉后达到只测试待测系统的目的。
  • mock server 拦在待测程序和依赖服务中间, 接收来自待测程序法来的请求。 用户会在 mock server 中编写规则匹配模式, 当发来的请求匹配到规则后 (比如 header 中带有特殊的字段) 会返回事前 mock 好的 response。 如果没有命中规则,则会把请求转发给真实的服务进行处理。 这样做的目的有二: 1. 系统过于复杂, 要完全 mock 所有接口成本过高,所以这种模式可以只 mock 核心的,耗时长的接口。 2. 普通用户访问系统不受影响, 因为普通用户的 header 里没带那些特殊字段, 所以正常使用。 而需要 mock server 的特定程序在测试请求中加了特殊字段所以可以利用 mock server 的能力,这样使得一个环境可以对双方开放, 互不影响。
  • 测试程序可以实现一套 case,在连接 mock server 的时候可以测试,而当把 mock server 下掉后依然可以运行的目的。 当然这需要对 case 和 mock 都有相应的设计。 这样的目的实现该模块自己开发的时候先用 mock server 进行测试,但是后面集成时完成相应的集成测试。 当然也可以直接维护两套 case, 一套对 mock server 的一套对真实环境的。 看每个人的选择

mock server 的开源工具

介绍一个我喜欢用的开源 mock server: https://www.mock-server.com/mock_server/running_mock_server.html
mock server 的规则可支持 java, json,js 3 种格式。比如 java 风格的:

public class MockServer {
    public static void main(String[] args) {

        ClientAndServer server = new ClientAndServer("localhost", 8080, 1080);

        server.when(
                request()
                        .withMethod("GET")
                        .withPath("/hello/say")
        ).respond(
                response()
                        .withBody("mock successfully")
        );
        server.when(
                request()
                        .withMethod("GET")
                        .withPath("/test")
                        .withQueryStringParameters(
                                param("p", "1")
                        )
        ).respond(
                response()
                        .withCookie(new Cookie("cKey", "cValue"))
                        .withBody("test1")
        );

        server.when(
                request()
                        .withMethod("GET")
                        .withPath("/test")
                        .withQueryStringParameters(
                                param("p", "2")
                        )
        ).respond(
                response()
                        .withBody("test2")
        );
    }

}
  • 上面 new 一个 ClientAndServer("localhost", 8080, 1080) 就是在启动一个 mock server。 前两个参数是真实服务的 ip 地址和端口号, mock server 一旦发现有请求没有命中事前定义的规则, 那么就会转发给真实的服务处理。 而第三个参数就是 mock server 自己的端口号了。
  • 之后的程序都是规则匹配了, 可以编写规则拦截特定的 cookie,header,path,参数。 命中规则后就可以返回特定的 response, 并且可以设定延迟时间, 也就是可以设定延迟个几秒再返回请求, 这个对于需要测试网络延迟场景的 case 比较有用, 比如我们在混沌工程中注入特定接口的延迟故障,就是这么来的。

也可以是 json 格式的,比如启动 mock server 的时候直接指定读取哪个 json 文件中的规则。 例如:

java -Dmockserver.initializationJsonPath=${mock_file_path} -jar mockserver-netty-5.8.1-jar-with-dependencies.jar -serverPort 1080 -proxyRemotePort ${dport} -logLevel INFO

[
  {
    "httpRequest": {
      "path": "/simpleFirst"
    },
    "httpResponse": {
      "body": "some first response"
    }
  },
  {
    "httpRequest": {
      "path": "/simpleSecond"
    },
    "httpResponse": {
      "body": "some second response"
    }
  }
]
  • 上面代码第一行是启动命令, 在官网上下载 mock server 的 netty 包后, 可以指定规则文件的路径
  • 上面代码中的 json 就是规则文件。

具体的语法和规则请大家移步官网

图形界面

mock server 本身提供了一个图形界面来实时查看 mock server 接受到请求和规则命中情况。 如下:

动态加入规则

当 mock server 启动后,依然是可以通过 rest API 动态往里面加入规则的。 比如:

curl -v -X PUT "http://172.27.128.8:31234/mockserver/expectation" -d '{
  "httpRequest" : {
    "method" : "GET",
    "path" : "/view/cart",
    "queryStringParameters" : {
      "cartId" : [ "055CA455-1DF7-45BB-8535-4F83E7266092" ]
    },
    "cookies" : {
      "session" : "4930456C-C718-476F-971F-CB8E047AB349"
    }
  },
  "httpResponse" : {
    "body" : "some_response_body"
  }
}'

这就会给 mock server 中加入了一个新的规则, 可以在 mock server 的 UI 上看到

动态加入规则的目的是在一些比较复杂的情况下, 比如 mock server 跟部署工具或者环境绑定在一起, 手动起停比较困难的情况下,动态加入有利于作为 workround 和调式 mock 规则时使用。 当然 mock server 同样提供一个好用的功能, 它会把你动态加入的这些规则保存到一个文件中。 只需要你启动的时候加入两个参数就可以。 比如:

java -Dmockserver.persistExpectations=true -Dmockserver.persistedExpectationsPath=mockserverInitialization.json  -Dmockserver.initializationJsonPath=${mock_file_path} -jar mockserver-netty-5.8.1-jar-with-dependencies.jar -serverPort 1080 -proxyRemotePort ${dport} -logLevel INFO

上面的命令比之前多了两个参数分别是-Dmockserver.persistExpectations=true -Dmockserver.persistedExpectationsPath=mockserverInitialization.json 。 这两个参数保证了当你在 mocker server 上动态加入规则后, 这个规则能保存在这个文件里。 比如:

这种模式比较方便你调试 mock 规则。

抓取 response

有些时候研发的接口文档规范很差, 甚至研发自己都不知道要 mock 的 response 长什么样子。 所以我们需要能抓取到实际返回的接口请求。 那么 mock server 也提供了这样一个功能。 我们可以动态的调用一个 api。 如下:

curl -v -X PUT "http://172.27.128.8:31234/mockserver/retrieve?type=REQUEST_RESPONSES" -d '{
    "path": "/grid/console",
    "method": "GET"
}'

上面的代码是在从 mock server 中所有 method 为 GET 路径为/grid/console 的请求和相应详情。 效果如下:

在 k8s 中的玩法

在微服务架构中, 一次测试中可能会需要很多个 mock server, 以为一个 mock server 只能 mock 一个真实的服务。 那么如何部署这些 mock server 就是我这几天在研究的。 我们的产品是部署在 k8s 中的, 借鉴我们在混沌工程中进行故障注入的实践方式,这一次我同样选择使用 side car 模式向服务所在 POD 中注入 mock server 容器。 如下:

  • 第一步先注入一个 init container, init container 就是 pod 的初始化容器, 我们注入这个初始化容器是为了使用 iptables 来修改网络规则, 把原本应该发送给真实服务的请求转发给 mock server
  • 第二部注入 mock server 容器, 这个容器启动时接管了所有的流量, 命中规则返回 mock response, 没有命中规则的话转发给真实服务。

由于在 k8s pod 中的所有容器都是共享网络名称空间的, 所以这些容器都是天然的网络互通的 (用 localhost 就可以访问了). 通过编写这样的工具,就可以做到对产品的部署方式上进行无入侵的解决方案。

实现方式

  • 语言:golang
  • 包: k8s 开源的 client-go
package main

import (
    "encoding/json"
    "flag"
    "fmt"
    "github.com/pkg/errors"
    log "github.com/sirupsen/logrus"
    "io/ioutil"
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    yamlUtil "k8s.io/apimachinery/pkg/util/yaml"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/tools/clientcmd"
    "os"
    "strings"
    "time"
)

var (
    isRecover bool
    kubeConfigPath string
    configPath string
)

const (
    secretName = "mock-server-secret"
    secretFilePath = "pull-secret.yaml"
    timeout = 10
    initContainerName = "mock-init"
    mockServerContainerName = "mock-server"
    mockServerContainerImage = "reg.4paradigm.com/qa/mock-server"
)

func init() {
    log.SetOutput(os.Stdout)
    log.SetLevel(log.DebugLevel)
}

func main() {
    flag.BoolVar(&isRecover, "r", false, "是否将环境中pod的mock server移除")
    flag.StringVar(&kubeConfigPath, "-kubeconfig", "kubeconfig", "k8s集群的kubeconfig文件, 工具需要此文件连接k8s集群")
    flag.StringVar(&configPath, "-config", "config.json", "工具的配置文件的路径,工具会根据此配置文件中的内容向业务pod中注入mock server")
    flag.Parse()

    log.Info("init the kubeconfig")
    kubeConfig, err := clientcmd.BuildConfigFromFlags("", kubeConfigPath)
    if err != nil {
        log.Error("cannot init the kubeconfig")
        panic(err.Error())
    }
    log.Info("init the k8sclient")
    k8s, err := kubernetes.NewForConfig(kubeConfig)
    if err != nil {
        log.Error("cannot init the k8s client")
        panic(err.Error())
    }

    configs, err := readConfig(configPath)
    if err != nil {
        handleErr(err, "Failed to load config %s ", "config.json")
    }

    deploys := make(map[string]string)
    for _, c := range configs {
        if isRecover {
            log.Debugf("start to reset mock server ns[%s] deployment[%s]", c.Namespace, c.DeploymentName)
            err = reset(k8s, c.Namespace, c.DeploymentName)
            if err != nil {
                handleErr(err, "Failed to reset the mock server. deployment=%s namespace=%s", c.Namespace, c.DeploymentName)
            }
            deploys[c.DeploymentName] = c.Namespace
        } else {
            err = addSecrets(k8s,c.Namespace)
            if err != nil {
                handleErr(err, "Failed to add Secrets to the namespace %s", c.Namespace)
            }
            log.Debugf("start to inject mock server ns[%s] deployment[%s] dport[%s] mockfile[%s]", c.Namespace, c.DeploymentName, c.Dport, c.MockFilePath)
            err = injectMockServer(k8s, c.Namespace, c.DeploymentName, c.Dport, c.MockFilePath)
            if err != nil {
                handleErr(err, "Failed to setup the mock server. deployment=%s namespace=%s", c.Namespace, c.DeploymentName)
            }
            deploys[c.DeploymentName] = c.Namespace
        }
    }

    err = waitDeploymentReady(k8s, deploys)
    if err != nil {
        fmt.Printf("err: %+v\n", err)
        os.Exit(1)
    }

    log.Info("Done")
}

func handleErr(err error, message string, v ...interface{}) {
    err = errors.WithMessagef(err, message, v)
    fmt.Printf("err: %+v\n", err)
    os.Exit(1)
}

func reset(k8s *kubernetes.Clientset, ns string, deploymentName string) error {
    deployment, err := k8s.AppsV1().Deployments(ns).Get(deploymentName, metav1.GetOptions{})
    if err != nil {
        return errors.Wrap(err, "Failed to get deployment")
    }

    initContainers := deployment.Spec.Template.Spec.InitContainers
    for index, i := range initContainers {
        if i.Name == "mock-init" {
            initContainers = append(initContainers[:index], initContainers[index+1:]...)
        }
    }
    deployment.Spec.Template.Spec.InitContainers = initContainers

    Containers := deployment.Spec.Template.Spec.Containers
    for index, i := range Containers {
        if i.Name == "mock-server" {
            Containers = append(Containers[:index], Containers[index+1:]...)
        }
    }
    deployment.Spec.Template.Spec.Containers = Containers

    _, err = k8s.AppsV1().Deployments(ns).Update(deployment)
    if err != nil {
        return errors.Wrap(err, "Failed to update deployment")
    }

    return nil
}

func injectMockServer(k8s *kubernetes.Clientset, ns string, deploymentName string, dport string, mockFilePath string) error {
    deployment, err := k8s.AppsV1().Deployments(ns).Get(deploymentName, metav1.GetOptions{})
    if err != nil {
        return errors.Wrap(err, "Failed to get deployment")
    }

    initContainers := deployment.Spec.Template.Spec.InitContainers
    isExist := false
    for _, i := range initContainers {
        if i.Name == initContainerName {
            isExist = true
        }
    }
    //iptables -t nat -A PREROUTING -p tcp --dport 7777 -j REDIRECT --to-port 6666
    if !isExist {
        s := &corev1.SecurityContext{
            Capabilities: &corev1.Capabilities{
                Add: []corev1.Capability{"NET_ADMIN"},
            },
        }

        mockServerInitContainer := corev1.Container{
            Image:           "biarca/iptables",
            ImagePullPolicy: corev1.PullIfNotPresent,
            Name:            initContainerName,
            Command: []string{
                "iptables",
                "-t",
                "nat",
                "-A",
                "PREROUTING",
                "-p",
                "tcp",
                "--dport",
                dport,
                "-j",
                "REDIRECT",
                "--to-port",
                "1080",
            },
            SecurityContext: s,
        }
        initContainers = append(initContainers, mockServerInitContainer)
        deployment.Spec.Template.Spec.InitContainers = initContainers
    }

    containers := deployment.Spec.Template.Spec.Containers
    isExist = false
    for _, i := range containers {
        if i.Name == mockServerContainerName {
            isExist = true
        }
    }
    if !isExist {
        c := corev1.Container{
            Image:           mockServerContainerImage,
            ImagePullPolicy: corev1.PullAlways,
            Name:            mockServerContainerName,
            Env: []corev1.EnvVar{
                {
                    Name:  "mock_file_path",
                    Value: mockFilePath,
                },
                {
                    Name:  "dport",
                    Value: dport,
                },
            },
        }
        containers = append(containers, c)
        deployment.Spec.Template.Spec.Containers = containers
        deployment.Spec.Template.Spec.ImagePullSecrets = append(deployment.Spec.Template.Spec.ImagePullSecrets, corev1.LocalObjectReference{Name: secretName})

        _, err = k8s.AppsV1().Deployments(ns).Update(deployment)
        if err != nil {
            return errors.Wrap(err, "Failed to update deployment")
        }
    }
    return nil
}

type Config struct {
    Namespace string `json:"namespace"`
    //KubeConfigPath string   `json:"kubeconfig_path"`
    DeploymentName string `json:"deployment_name"`
    Dport          string `json:"dport"`
    MockFilePath   string `json:"mock_file_path"`
}

func readConfig(path string) ([]Config, error) {
    var config []Config

    data, err := ioutil.ReadFile(path)
    if err != nil {
        return nil, errors.Wrap(err, "ready file failed")
    }

    err = json.Unmarshal(data, &config)
    if err != nil {
        return nil, errors.Wrap(err, "unmarshal json failed")
    }
    return config, nil
}

func addSecrets(k8s *kubernetes.Clientset, ns string) error {
    _, err := k8s.CoreV1().Secrets(ns).Get(secretName, metav1.GetOptions{})
    if err != nil {
        if !strings.Contains(err.Error(), "not found") {
            return errors.Wrapf(err, "get secret %s", secretName)
        }else {
            log.Debugf("namespace[%s] has no secret, now create one", ns)
            var (
                err    error
                data   []byte
            )
            if data, err = ioutil.ReadFile(secretFilePath); err != nil {
                return errors.Wrapf(err, "read %s", secretFilePath)
            }
            if data, err = yamlUtil.ToJSON(data); err != nil {
                return errors.Wrap(err, "covert yaml to json")
            }
            se := &corev1.Secret{}
            if err = json.Unmarshal(data, se); err != nil {
                return errors.Wrap(err, "json unmarshal to secret")
            }
            //cluster := se.ObjectMeta.ClusterName
            //namespace := se.ObjectMeta.Namespace
            //seName := se.ObjectMeta.Name
            se.Namespace = ns
            if _, err = k8s.CoreV1().Secrets(ns).Create(se); err != nil {
                return errors.Wrap(err, "create secret failed")
            }
            return nil
        }
    }
    return nil
}


func waitDeploymentReady(k8s *kubernetes.Clientset, deploys map[string]string) error {
    now := time.Now()
    time.Sleep(time.Second * 1)
    for deploymentName, ns := range deploys {
        deploy, err := k8s.AppsV1().Deployments(ns).Get(deploymentName, metav1.GetOptions{})
        if err != nil {
            return errors.Wrapf(err, "Failed to get deployment[%s] ns[%s]", deploymentName, ns)
        }

        sumReplica := deploy.Status.UnavailableReplicas + deploy.Status.AvailableReplicas
        for deploy.Status.ReadyReplicas != *deploy.Spec.Replicas || sumReplica != *deploy.Spec.Replicas {
            if time.Now().Sub(now) > time.Minute*timeout {
                return errors.Wrapf(err, "deployment is not ready name:%s  ns:%s", deploymentName, ns)
            }

            deploy, err = k8s.AppsV1().Deployments(ns).Get(deploymentName, metav1.GetOptions{})
            if err != nil {
                return errors.Wrapf(err, "Failed to get deployment[%s] ns[%s]", deploymentName, ns)
            }
            time.Sleep(time.Second * 5)
            sumReplica = deploy.Status.UnavailableReplicas + deploy.Status.AvailableReplicas
            log.Debugf("Waiting: the deploy[%s] the spec replica is %d, readyRelicas is %d, unavail replica is %d, avail replica is %d",
                deploy.Name, *deploy.Spec.Replicas, deploy.Status.ReadyReplicas, deploy.Status.UnavailableReplicas, deploy.Status.AvailableReplicas)
        }
    }
    return nil
}

dockerfile:

FROM docker.4pd.io/base-image-openjdk8:1.0.1

RUN yum install -y git
RUN wget -O "mockserver-netty-5.8.1-jar-with-dependencies.jar" "http://search.maven.org/remotecontent?filepath=org/mock-server/mockserver-netty/5.8.1/mockserver-netty-5.8.1-jar-with-dependencies.jar"

EXPOSE 1080

ENTRYPOINT git clone https://xxxx:xxxx@xxxxxxxxx.git && cp mock-server-tools/${mock_file_path} .  && bash -x java -Dmockserver.persistExpectations=true -Dmockserver.persistedExpectationsPath=mockserverInitialization.json  -Dmockserver.initializationJsonPath=${mock_file_path} -jar mockserver-netty-5.8.1-jar-with-dependencies.jar -serverPort 1080 -proxyRemotePort ${dport} -logLevel INFO
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 15 条回复 时间 点赞

之前都是用 moco 来模拟,现在又学习了一种,很强大,感谢分享

mark 学习

对于模拟 3 方服务回调的 mock,以方便自动化 case 的运行,是使数据固定话好还是尽量动态化好呢

jackie 回复

都行,看场景需要

陈恒捷 将本帖设为了精华贴 12月04日 00:20

命中规则返回 mock response, 没有命中规则的话转发给真实服务。

这个是这个 java 应用直接具备的功能,还是通过文中的 go 脚本实现的?看了下 go 脚本,貌似没见到有相关的逻辑。文中的 java mockserver 有提供 proxy 的方式,但也必须要在被测服务里加配置才能实现。

目前实际项目里,需要用到这种指定规则的 mock,非指定的走真实流量。找到的各种 mock 基本都是 mock 整个服务,或者需要到被测系统里加配置,缺少类似网关的实现方式自由选择哪些 mock 哪些直接转发,不大合适。

陈恒捷 回复

是这个 mock server 直接具备的功能,不用二次开发, 它启动的时候可以设置 proxy 模式, 填写真实服务的 ip 地址和端口号。 我 go 的代码只是把 mock server 注入到了我们 k8s 集群里了,只做了这一个事而已。

看你的描述我觉得它的能力是符合你的需求的。 不过你们是什么场景需要这个能力的?

孙高飞 回复

开发场景,客户端和服务端同步开发,客户端前期需要通过 mock 获取所需返回值。
测试场景也偶尔会用到,用于模拟返回特定错误码。

试用了下 port forwarding 的 proxy 方法,配置了 proxyRemotePort 和 proxyRemoteHost 。通过 mock-server 访问返回 404,但直接访问是可以的,看来还要再看看具体源码了。

陈恒捷 回复

应该是配置错了吧,我这是可以路由的

陈恒捷 回复

知道了,踩了暗坑,port forward 模式下转发时,一些和 host 有关的内容没有做替换,只是把内容做了转发。比如 header 里的 host 没改为 proxyRemoteHost 的值。所以背后真实服务看到 host 不大对,就不会正常返回了。

我实际启动命令里的相关配置:

java \
-Dmockserver.initializationJsonPath=${mock_file_path} \
-Dmockserver.watchInitializationJson=true \
-jar  mockserver-netty-5.11.1-jar-with-dependencies.jar -serverPort 1080 \
-proxyRemotePort 80 \
-proxyRemoteHost xxx.lizhi.fm

根据日志,转发时使用的 Url 和 header 中 host 字段还是 127.0.0.1:1080 ,不是我配置的 proxyRemote 相关信息。而且通过查服务端日志,会没有相关日志,估计是被框架层处理,都到不了业务逻辑层。

给请求加上 -H 'Host: xxx.lizhi.fm' 后,才能正常返回。详细的后面得再看看源码。

陈恒捷 回复

还有这个坑呢。 我还没碰见😂

陈恒捷 回复


这个坑,可以用框架的另外一个功能解决掉:定义一个全局的、优先级为-1 的、匹配规则为/* 的 override 期望,在转发之前重写请求,获取原请求的 host 或者其他请求头信息,添加到转发请求里,类似图中这样的。最近在用这个 apache mockserver 搭部门的 mock 平台,也遇到了这个问题。顺便强烈建议大佬尝试下这个框架的录制回放功能,贼好用。

我们的业务没有录制回放的需求, 所以没太研究, 你能分享一下么? PS: 创建这个期望的代码能贴一下么

孙高飞 回复

public void setGlobalProxy(ServerClient server, String proxyAddress, HttpForward.Scheme scheme){

int port = scheme == HttpForward.Scheme.HTTPS?443:80;
boolean isSecure = scheme == HttpForward.Scheme.HTTPS;

Expectation proxyExpectation = new Expectation(
request().withPath("/.*")
).withPriority(-1).thenForward(
HttpOverrideForwardedRequest.forwardOverriddenRequest(
request().withHeader("Host",proxyAddress)
.withSocketAddress(proxyAddress,port)
.withSecure(isSecure)
)
);

server.upsert(proxyExpectation);
}
分享就有点不敢了,水平太差,大佬有兴趣的话,可以看下 apache mockserver 源码里的 example module,结合 mockserver-core module 的测试类来看体验更好,那里面有基本场景的代码样例。

个人使用 wiremock 比较多,看着功能上和 mock server 大同小异

建议有 mock 第三方服务需求的测试,上手 flask 吧,自己写个 mock 服务,丢到服务器上部署运行。你会打开一扇新的大门,从此你就会对这些已封装好的 mock 库呲之以鼻。不吹不黑,fastapi 也可以的,但我记得不支持 webservice。

3楼 已删除
2楼 已删除
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册