Plugin automatic test framework

Plugin test framework is designed for verifying the plugins' function and compatible status. As there are dozens of plugins and hundreds of versions need to be verified, it is impossible to do manually. The test framework uses container based tech stack, requires a set of real services with agent installed, then the test mock OAP backend is running to check the segments data sent from agents.

Every plugin maintained in the main repo requires corresponding test cases, also matching the versions in the supported list doc.

Environment Requirements

  1. MacOS/Linux
  2. JDK 8+
  3. Docker
  4. Docker Compose

Case Base Image Introduction

The test framework provides JVM-container and Tomcat-container base images including JDK8, JDK14. You could choose the suitable one for your test case, if both are suitable, JVM-container is preferred.

JVM-container Image Introduction

JVM-container uses openjdk:8 as the base image. JVM-container has supported JDK14, which inherits openjdk:14. The test case project is required to be packaged as project-name.zip, including startup.sh and uber jar, by using mvn clean package.

Take the following test projects as good examples

Tomcat-container Image Introduction

Tomcat-container uses tomcat:8.5.57-jdk8-openjdk or tomcat:8.5.57-jdk14-openjdk as the base image. The test case project is required to be packaged as project-name.war by using mvn package.

Take the following test project as a good example

Test project hierarchical structure

The test case is an independent maven project, and it is required to be packaged as a war tar ball or zip file, depends on the chosen base image. Also, two external accessible endpoints, mostly two URLs, are required.

All test case codes should be in org.apache.skywalking.apm.testcase.* package, unless there are some codes expected being instrumented, then the classes could be in test.org.apache.skywalking.apm.testcase.* package.

JVM-container test project hierarchical structure

[plugin-scenario]
    |- [bin]
        |- startup.sh
    |- [config]
        |- expectedData.yaml
    |- [src]
        |- [main]
            |- ...
        |- [resource]
            |- log4j2.xml
    |- pom.xml
    |- configuration.yaml
    |- support-version.list

[] = directory

Tomcat-container test project hierarchical structure

[plugin-scenario]
    |- [config]
        |- expectedData.yaml
    |- [src]
        |- [main]
            |- ...
        |- [resource]
            |- log4j2.xml
        |- [webapp]
            |- [WEB-INF]
                |- web.xml
    |- pom.xml
    |- configuration.yaml
    |- support-version.list

[] = directory

Test case configuration files

The following files are required in every test case.

File NameDescriptions
configuration.ymlDeclare the basic case inform, including, case name, entrance endpoints, mode, dependencies.
expectedData.yamlDescribe the expected segmentItems.
support-version.listList the target versions for this case
startup.shJVM-container only, don't need this when useTomcat-container

* support-version.list format requires every line for a single version(Contains only the last version number of each minor version). Could use # to comment out this version.

configuration.yml

Fielddescription
typeImage type, options, jvm or tomcat. Required.
entryServiceThe entrance endpoint(URL) for test case access. Required. (HTTP Method: GET)
healthCheckThe health check endpoint(URL) for test case access. Required. (HTTP Method: HEAD)
startScriptPath of start up script. Required in type: jvm only.
frameworkCase name.
runningModeRunning mode whether with the optional plugin, options, default(default), with_optional, with_bootstrap
withPluginsPlugin selector rule. eg:apm-spring-annotation-plugin-*.jar. Required when runningMode=with_optional or runningMode=with_bootstrap.
environmentSame as docker-compose#environment.
depends_onSame as docker-compose#depends_on.
dependenciesSame as docker-compose#services, image、links、hostname、environment、depends_on are supported.

Notice:, docker-compose active only when dependencies is only blank.

runningMode option description.

Optiondescription
defaultActive all plugins in plugin folder like the official distribution agent.
with_optionalActive default and plugins in optional-plugin by the give selector.
with_bootstrapActive default and plugins in bootstrap-plugin by the give selector.

with_optional/with_bootstrap supports multiple selectors, separated by ;.

File Format

type:
entryService:
healthCheck:
startScript:
framework:
runningMode:
withPlugins:
environment:
  ...
depends_on:
  ...
dependencies:
  service1:
    image:
    hostname: 
    expose:
      ...
    environment:
      ...
    depends_on:
      ...
    links:
      ...
    entrypoint:
      ...
    healthcheck:
      ...
  • dependencies supports docker compose healthcheck. But the format is a little difference. We need - as the start of every config item, and describe it as a string line.

Such as in official doc, the health check is

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost"]
  interval: 1m30s
  timeout: 10s
  retries: 3
  start_period: 40s

The here, you should write as

healthcheck:
  - 'test: ["CMD", "curl", "-f", "http://localhost"]'
  - "interval: 1m30s"
  - "timeout: 10s"
  - "retries: 3"
  - "start_period: 40s"

In some cases, the dependency service, mostly 3rd party server like SolrJ server, is required to keep the same version as client lib version, which defined as ${test.framework.version} in pom. Could use ${CASE_SERVER_IMAGE_VERSION} as the version number, it will be changed in the test for every version.

Don‘t support resource related configurations, such as volumes, ports and ulimits. Because in test scenarios, don’t need mapping any port to the host VM, or mount any folder.

Take following test cases as examples

expectedData.yaml

Operator for number

OperatorDescription
nqNot equal
eqEqual(default)
geGreater than or equal
gtGreater than

Operator for String

OperatorDescription
not nullNot null
nullNull or empty String
eqEqual(default)

Expected Data Format Of The Segment

segmentItems:
-
  serviceName: SERVICE_NAME(string)
  segmentSize: SEGMENT_SIZE(int)
  segments:
  - segmentId: SEGMENT_ID(string)
    spans:
        ...
FieldDescription
serviceNameService Name.
segmentSizeThe number of segments is expected.
segmentIdtrace ID.
spanssegment span list. Follow the next section to see how to describe every span.

Expected Data Format Of The Span

Notice: The order of span list should follow the order of the span finish time.

    operationName: OPERATION_NAME(string)
    parentSpanId: PARENT_SPAN_ID(int)
    spanId: SPAN_ID(int)
    startTime: START_TIME(int)
    endTime: END_TIME(int)
    isError: IS_ERROR(string: true, false)
    spanLayer: SPAN_LAYER(string: DB, RPC_FRAMEWORK, HTTP, MQ, CACHE)
    spanType: SPAN_TYPE(string: Exit, Entry, Local)
    componentId: COMPONENT_ID(int)
    tags:
    - {key: TAG_KEY(string), value: TAG_VALUE(string)}
    ...
    logs:
    - {key: LOG_KEY(string), value: LOG_VALUE(string)}
    ...
    peer: PEER(string)
    refs:
    - {
       traceId: TRACE_ID(string),
       parentTraceSegmentId: PARENT_TRACE_SEGMENT_ID(string),
       parentSpanId: PARENT_SPAN_ID(int),
       parentService: PARENT_SERVICE(string),
       parentServiceInstance: PARENT_SERVICE_INSTANCE(string),
       parentEndpoint: PARENT_ENDPOINT_NAME(string),
       networkAddress: NETWORK_ADDRESS(string),
       refType:  REF_TYPE(string: CrossProcess, CrossThread)
     }
   ...
FieldDescription
operationNameSpan Operation Name.
parentSpanIdParent span id. Notice: The parent span id of the first span should be -1.
spanIdSpan Id. Notice, start from 0.
startTimeSpan start time. It is impossible to get the accurate time, not 0 should be enough.
endTimeSpan finish time. It is impossible to get the accurate time, not 0 should be enough.
isErrorSpan status, true or false.
componentIdComponent id for your plugin.
tagsSpan tag list. Notice, Keep in the same order as the plugin coded.
logsSpan log list. Notice, Keep in the same order as the plugin coded.
SpanLayerOptions, DB, RPC_FRAMEWORK, HTTP, MQ, CACHE.
SpanTypeSpan type, options, Exit, Entry or Local.
peerRemote network address, IP + port mostly. For exit span, this should be required.

The verify description for SegmentRef

FieldDescription
traceId
parentTraceSegmentIdParent SegmentId, pointing to the segment id in the parent segment.
parentSpanIdParent SpanID, pointing to the span id in the parent segment.
parentServiceThe service of parent/downstream service name.
parentServiceInstanceThe instance of parent/downstream service instance name.
parentEndpointThe endpoint of parent/downstream service.
networkAddressThe peer value of parent exit span.
refTypeRef type, options, CrossProcess or CrossThread.

Expected Data Format Of The Meter Items

meterItems:
-
  serviceName: SERVICE_NAME(string)
  meterSize: METER_SIZE(int)
  meters:
  - ...
FieldDescription
serviceNameService Name.
meterSizeThe number of meters is expected.
metersmeter list. Follow the next section to see how to describe every meter.

Expected Data Format Of The Meter

    meterId: 
        name: NAME(string)
        tags:
        - {name: TAG_NAME(string), value: TAG_VALUE(string)}
    singleValue: SINGLE_VALUE(double)
    histogramBuckets:
    - HISTOGRAM_BUCKET(double)
    ...

The verify description for MeterId

FieldDescription
namemeter name.
tagsmeter tags.
tags.nametag name.
tags.valuetag value.
singleValuecounter or gauge value. Using condition operate of the number to validate, such as gt, ge. If current meter is histogram, don't need to write this field.
histogramBucketshistogram bucket. The bucket list must be ordered. The tool assert at least one bucket of the histogram having nonzero count. If current meter is counter or gauge, don't need to write this field.

startup.sh

This script provide a start point to JVM based service, most of them starts by a java -jar, with some variables. The following system environment variables are available in the shell.

VariableDescription
agent_optsAgent plugin opts, check the detail in plugin doc or the same opt added in this PR.
SCENARIO_NAMEService name. Default same as the case folder name
SCENARIO_VERSIONVersion
SCENARIO_ENTRY_SERVICEEntrance URL to access this service
SCENARIO_HEALTH_CHECK_URLHealth check URL

${agent_opts} is required to add into your java -jar command, which including the parameter injected by test framework, and make agent installed. All other parameters should be added after ${agent_opts}.

The test framework will set the service name as the test case folder name by default, but in some cases, there are more than one test projects are required to run in different service codes, could set it explicitly like the following example.

Example

home="$(cd "$(dirname $0)"; pwd)"

java -jar ${agent_opts} "-Dskywalking.agent.service_name=jettyserver-scenario" ${home}/../libs/jettyserver-scenario.jar &
sleep 1

java -jar ${agent_opts} "-Dskywalking.agent.service_name=jettyclient-scenario"  ${home}/../libs/jettyclient-scenario.jar &

Only set this or use other skywalking options when it is really necessary.

Take the following test cases as examples

Best Practices

How To Use The Archetype To Create A Test Case Project

We provided archetypes and a script to make creating a project easier. It creates a completed project of a test case. So that we only need to focus on cases. First, we can use followed command to get usage about the script.

bash ${SKYWALKING_HOME}/test/plugin/generator.sh

Then, runs and generates a project, named by scenario_name, in ./scenarios.

Recommendations for pom

    <properties>
        <!-- Provide and use this property in the pom. -->
        <!-- This version should match the library version, -->
        <!-- in this case, http components lib version 4.3. -->
        <test.framework.version>4.3</test.framework.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>${test.framework.version}</version>
        </dependency>
        ...
    </dependencies>

    <build>
        <!-- Set the package final name as same as the test case folder case. -->
        <finalName>httpclient-4.3.x-scenario</finalName>
        ....
    </build>

How To Implement Heartbeat Service

Heartbeat service is designed for checking the service available status. This service is a simple HTTP service, returning 200 means the target service is ready. Then the traffic generator will access the entry service and verify the expected data. User should consider to use this service to detect such as whether the dependent services are ready, especially when dependent services are database or cluster.

Notice, because heartbeat service could be traced fully or partially, so, segmentSize in expectedData.yaml should use ge as the operator, and don't include the segments of heartbeat service in the expected segment data.

The example Process of Writing Tracing Expected Data

Expected data file, expectedData.yaml, include SegmentItems part.

We are using the HttpClient plugin to show how to write the expected data.

There are two key points of testing

  1. Whether is HttpClient span created.
  2. Whether the ContextCarrier created correctly, and propagates across processes.
+-------------+         +------------------+            +-------------------------+
|   Browser   |         |  Case Servlet    |            | ContextPropagateServlet |
|             |         |                  |            |                         |
+-----|-------+         +---------|--------+            +------------|------------+
      |                           |                                  |
      |                           |                                  |
      |       WebHttp            +-+                                 |
      +------------------------> |-|         HttpClient             +-+
      |                          |--------------------------------> |-|
      |                          |-|                                |-|
      |                          |-|                                |-|
      |                          |-| <--------------------------------|
      |                          |-|                                +-+
      | <--------------------------|                                 |
      |                          +-+                                 |
      |                           |                                  |
      |                           |                                  |
      |                           |                                  |
      |                           |                                  |
      +                           +                                  +

segmentItems

By following the flow of HttpClient case, there should be two segments created.

  1. Segment represents the CaseServlet access. Let's name it as SegmentA.
  2. Segment represents the ContextPropagateServlet access. Let's name it as SegmentB.
segmentItems:
  - serviceName: httpclient-case
    segmentSize: ge 2 # Could have more than one health check segments, because, the dependency is not standby.

Because Tomcat plugin is a default plugin of SkyWalking, so, in SegmentA, there are two spans

  1. Tomcat entry span
  2. HttpClient exit span

SegmentA span list should like following

    - segmentId: not null
      spans:
        - operationName: /httpclient-case/case/context-propagate
          parentSpanId: 0
          spanId: 1
          startTime: nq 0
          endTime: nq 0
          isError: false
          spanLayer: Http
          spanType: Exit
          componentId: eq 2
          tags:
            - {key: url, value: 'http://127.0.0.1:8080/httpclient-case/case/context-propagate'}
            - {key: http.method, value: GET}
          logs: []
          peer: 127.0.0.1:8080
        - operationName: /httpclient-case/case/httpclient
          parentSpanId: -1
          spanId: 0
          startTime: nq 0
          endTime: nq 0
          spanLayer: Http
          isError: false
          spanType: Entry
          componentId: 1
          tags:
            - {key: url, value: 'http://localhost:{SERVER_OUTPUT_PORT}/httpclient-case/case/httpclient'}
            - {key: http.method, value: GET}
          logs: []
          peer: null

SegmentB should only have one Tomcat entry span, but includes the Ref pointing to SegmentA.

SegmentB span list should like following

- segmentId: not null
  spans:
  -
   operationName: /httpclient-case/case/context-propagate
   parentSpanId: -1
   spanId: 0
   tags:
   - {key: url, value: 'http://127.0.0.1:8080/httpclient-case/case/context-propagate'}
   - {key: http.method, value: GET}
   logs: []
   startTime: nq 0
   endTime: nq 0
   spanLayer: Http
   isError: false
   spanType: Entry
   componentId: 1
   peer: null
   refs:
    - {parentEndpoint: /httpclient-case/case/httpclient, networkAddress: 'localhost:8080', refType: CrossProcess, parentSpanId: 1, parentTraceSegmentId: not null, parentServiceInstance: not null, parentService: not null, traceId: not null}

The example Process of Writing Meter Expected Data

Expected data file, expectedData.yaml, include MeterItems part.

We are using the toolkit plugin to demonstrate how to write the expected data. When write the meter plugin, the expected data file keeps the same.

There is one key point of testing

  1. Build a meter and operate it.

Such as Counter:

MeterFactory.counter("test_counter").tag("ck1", "cv1").build().increment(1d);
MeterFactory.histogram("test_histogram").tag("hk1", "hv1").steps(1d, 5d, 10d).build().addValue(2d);
+-------------+         +------------------+
|   Plugin    |         |    Agent core    |
|             |         |                  |
+-----|-------+         +---------|--------+
      |                           |         
      |                           |         
      |    Build or operate      +-+        
      +------------------------> |-|        
      |                          |-]
      |                          |-|        
      |                          |-|        
      |                          |-|
      |                          |-|        
      | <--------------------------|        
      |                          +-+        
      |                           |         
      |                           |         
      |                           |         
      |                           |         
      +                           +         

meterItems

By following the flow of the toolkit case, there should be two meters created.

  1. Meter test_counter created from MeterFactory#counter. Let's name it as MeterA.
  2. Meter test_histogram created from MeterFactory#histogram. Let's name it as MeterB.
meterItems:
  - serviceName: toolkit-case
    meterSize: 2

They're showing two kinds of meter, MeterA has a single value, MeterB has a histogram value.

MeterA should like following, counter and gauge use the same data format.

- meterId:
    name: test_counter
    tags:
      - {name: ck1, value: cv1}
  singleValue: gt 0

MeterB should like following.

- meterId:
    name: test_histogram
    tags:
      - {name: hk1, value: hv1}
  histogramBuckets:
    - 0.0
    - 1.0
    - 5.0
    - 10.0

Local Test and Pull Request To The Upstream

First of all, the test case project could be compiled successfully, with right project structure and be able to deploy. The developer should test the start script could run in Linux/MacOS, and entryService/health services are able to provide the response.

You could run test by using following commands

cd ${SKYWALKING_HOME}
bash ./test/plugin/run.sh -f ${scenario_name}

Notice,if codes in ./apm-sniffer have been changed, no matter because your change or git update, please recompile the skywalking-agent. Because the test framework will use the existing skywalking-agent folder, rather than recompiling it every time.

Use ${SKYWALKING_HOME}/test/plugin/run.sh -h to know more command options.

If the local test passed, then you could add it to .github/workflows/plugins-test.<n>.yaml file, which will drive the tests running on the GitHub Actions of official SkyWalking repository. Based on your plugin's name, please add the test case into file .github/workflows/plugins-test.<n>.yaml, by alphabetical orders.

Every test case is a GitHub Actions Job. Please use the scenario directory name as the case name, mostly you'll just need to decide which file (plugins-test.<n>.yaml) to add your test case, and simply put one line (as follows) in it, take the existed cases as examples. You can run python3 tools/select-group.py to see which file contains the least cases and add your cases into it, in order to balance the running time of each group.

If a test case required to run in JDK 14 environment, please add you test case into file plugins-jdk14-test.<n>.yaml.

jobs:
  PluginsTest:
    name: Plugin
    runs-on: ubuntu-18.04
    timeout-minutes: 90
    strategy:
      fail-fast: true
      matrix:
        case:
          # ...
          - <your scenario test directory name>
          # ...