Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,32 @@ To use bktec, you need to configure the `BUILDKITE_TEST_ENGINE_SUITE_SLUG` envir
export BUILDKITE_TEST_ENGINE_SUITE_SLUG=my-slug
```

### Upload test results to Test Engine

bktec needs to collect your test data to enable features like intelligent test splitting, retry, and muting. There are two ways to do this:

**Option 1: Use bktec's built-in upload (requires bktec 2.7.0 or later)**

Set `BUILDKITE_TEST_ENGINE_UPLOAD_RESULTS` to `true`:

```sh
export BUILDKITE_TEST_ENGINE_UPLOAD_RESULTS=true
```

You can attach key/value tags to each upload using `--tag` or `BUILDKITE_TEST_ENGINE_TAGS`. Tags are useful for filtering and grouping test results in Test Engine.

```sh
# As CLI flags (repeatable)
bktec run --tag env=production --tag region=us-east-1

# As an environment variable (comma-separated)
export BUILDKITE_TEST_ENGINE_TAGS="env=production,region=us-east-1"
```

**Option 2: Install a [Buildkite Test Collector](https://buildkite.com/docs/test-engine/test-collection)**

Test collectors are available for many languages and frameworks. Some collectors also provide richer data collection such as execution-level tagging and span tracing. See the [test collector docs](https://buildkite.com/docs/test-engine/test-collection) for details on what's available for your framework.

### Preview: Test Selection
You can pass test selection strategy configuration and additional change context to the test plan API request.
This preview is enabled only when `BKTEC_PREVIEW_SELECTION` is truthy (`1`, `true`, `yes`, or `on`).
Expand Down
23 changes: 23 additions & 0 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,28 @@ var uploadResultsFlag = &cli.BoolFlag{
Destination: &cfg.UploadResults,
}

var uploadTagsFlag = &cli.StringSliceFlag{
Name: "tag",
Category: "TEST ENGINE",
Usage: "Additional key=value tags to attach to the upload. Repeat for multiple entries. When using the environment variable, separate multiple tags with commas.",
Sources: cli.EnvVars("BUILDKITE_TEST_ENGINE_TAGS"),
Action: func(_ context.Context, _ *cli.Command, vals []string) error {
// DisableSliceFlagSeparator on the run command prevents the CLI library
// from splitting comma-separated env var values automatically, so we do
// it here to support BUILDKITE_TEST_ENGINE_TAGS="key1=val1,key2=val2".
var entries []string
for _, v := range vals {
entries = append(entries, strings.Split(v, ",")...)
Comment thread
nprizal marked this conversation as resolved.
}
tags, err := parseKeyValueEntries(entries, "upload tag")
if err != nil {
return fmt.Errorf("invalid upload tags: %w", err)
}
cfg.UploadTags = tags
return nil
},
}

// Test Runner specific flags
var filesFlag = &cli.StringFlag{
Name: "files",
Expand Down Expand Up @@ -518,6 +540,7 @@ var testEngineFlags = []cli.Flag{
accessTokenFlag,
uploadTokenFlag,
uploadResultsFlag,
uploadTagsFlag,
suiteSlugFlag,
baseURLFlag,
uploadBaseURLFlag,
Expand Down
7 changes: 7 additions & 0 deletions cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"reflect"
"testing"
"time"

Expand Down Expand Up @@ -150,6 +151,7 @@ func TestRunCommandEnvVarsBindToConfig(t *testing.T) {
t.Setenv("BUILDKITE_TEST_ENGINE_DEBUG_ENABLED", "true")
t.Setenv("BUILDKITE_TEST_ENGINE_OIDC", "false")
t.Setenv("BUILDKITE_TEST_ENGINE_OIDC_LIFETIME", "1h")
t.Setenv("BUILDKITE_TEST_ENGINE_TAGS", "env=production,region=us-east-1")

cmd := &cli.Command{
Name: "bktec",
Expand Down Expand Up @@ -209,4 +211,9 @@ func TestRunCommandEnvVarsBindToConfig(t *testing.T) {
t.Errorf("cfg.%s = %v, want %v", c.name, c.got, c.want)
}
}

wantUploadTags := map[string]string{"env": "production", "region": "us-east-1"}
if !reflect.DeepEqual(cfg.UploadTags, wantUploadTags) {
t.Errorf("cfg.UploadTags = %v, want %v", cfg.UploadTags, wantUploadTags)
}
}
12 changes: 9 additions & 3 deletions internal/api/upload_test_results.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import (
// (e.g. "rspec-json", "jest-json").
// Errors are returned to the caller to be logged and suppressed — this upload
// is best-effort and must not fail the build.
func (c *Client) UploadTestResults(ctx context.Context, token string, filePath string, format string, locationPrefix string) error {
body, contentType, err := buildTestResultsMultipartBody(filePath, format, locationPrefix)
func (c *Client) UploadTestResults(ctx context.Context, token string, filePath string, format string, locationPrefix string, tags map[string]string) error {
body, contentType, err := buildTestResultsMultipartBody(filePath, format, locationPrefix, tags)
if err != nil {
return err
}
Expand Down Expand Up @@ -50,7 +50,7 @@ func (c *Client) UploadTestResults(ctx context.Context, token string, filePath s
return nil
}

func buildTestResultsMultipartBody(filePath string, format string, locationPrefix string) (*bytes.Buffer, string, error) {
func buildTestResultsMultipartBody(filePath string, format string, locationPrefix string, tags map[string]string) (*bytes.Buffer, string, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)

Expand Down Expand Up @@ -80,6 +80,12 @@ func buildTestResultsMultipartBody(filePath string, format string, locationPrefi
}
}

for k, v := range tags {
if err := w.WriteField(fmt.Sprintf("tags[%s]", k), v); err != nil {
return nil, "", fmt.Errorf("writing tags[%s]: %w", k, err)
}
}

fw, err := w.CreateFormFile("data", filepath.Base(filePath))
if err != nil {
return nil, "", fmt.Errorf("creating form file: %w", err)
Expand Down
41 changes: 36 additions & 5 deletions internal/api/upload_test_results_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func TestUploadTestResults(t *testing.T) {
defer svr.Close()

client := NewClient(ClientConfig{UploadBaseURL: svr.URL})
err = client.UploadTestResults(t.Context(), "my-token", resultFile.Name(), "rspec-json", "./")
err = client.UploadTestResults(t.Context(), "my-token", resultFile.Name(), "rspec-json", "./", nil)
require.NoError(t, err)

assert.Equal(t, "Token token=my-token", gotToken)
Expand All @@ -73,7 +73,7 @@ func TestUploadTestResults_ServerError(t *testing.T) {
defer svr.Close()

client := NewClient(ClientConfig{UploadBaseURL: svr.URL})
err = client.UploadTestResults(t.Context(), "my-token", resultFile.Name(), "rspec-json", "")
err = client.UploadTestResults(t.Context(), "my-token", resultFile.Name(), "rspec-json", "", nil)
assert.ErrorContains(t, err, "upload failed with status 500")
}

Expand All @@ -84,7 +84,7 @@ func TestUploadTestResults_MissingFile(t *testing.T) {
defer svr.Close()

client := NewClient(ClientConfig{UploadBaseURL: svr.URL})
err := client.UploadTestResults(t.Context(), "my-token", "/nonexistent/path/results.json", "rspec-json", "")
err := client.UploadTestResults(t.Context(), "my-token", "/nonexistent/path/results.json", "rspec-json", "", nil)
assert.ErrorContains(t, err, "opening result file")
}

Expand All @@ -100,7 +100,7 @@ func TestBuildTestResultsMultipartBody(t *testing.T) {
require.NoError(t, err)
resultFile.Close()

buf, contentType, err := buildTestResultsMultipartBody(resultFile.Name(), "rspec-json", "my/prefix")
buf, contentType, err := buildTestResultsMultipartBody(resultFile.Name(), "rspec-json", "my/prefix", nil)
require.NoError(t, err)
assert.True(t, strings.HasPrefix(contentType, "multipart/form-data"))

Expand Down Expand Up @@ -134,6 +134,37 @@ func TestBuildTestResultsMultipartBody(t *testing.T) {
assert.Equal(t, cwd, fields["run_env[cwd]"])
}

func TestBuildTestResultsMultipartBody_WithTags(t *testing.T) {
resultFile, err := os.CreateTemp("", "results-*.json")
require.NoError(t, err)
defer os.Remove(resultFile.Name())
resultFile.Close()

tags := map[string]string{"env": "production", "team": "platform"}
buf, contentType, err := buildTestResultsMultipartBody(resultFile.Name(), "rspec-json", "", tags)
require.NoError(t, err)

_, params, err := mime.ParseMediaType(contentType)
require.NoError(t, err)

fields := map[string]string{}
mr := multipart.NewReader(buf, params["boundary"])
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
require.NoError(t, err)
val, _ := io.ReadAll(part)
if part.FormName() != "" {
fields[part.FormName()] = string(val)
}
}

assert.Equal(t, "production", fields["tags[env]"])
assert.Equal(t, "platform", fields["tags[team]"])
}

func TestBuildTestResultsMultipartBody_NoCwdOutsideBuildkite(t *testing.T) {
t.Setenv("BUILDKITE_BUILD_ID", "")

Expand All @@ -142,7 +173,7 @@ func TestBuildTestResultsMultipartBody_NoCwdOutsideBuildkite(t *testing.T) {
defer os.Remove(resultFile.Name())
resultFile.Close()

buf, contentType, err := buildTestResultsMultipartBody(resultFile.Name(), "rspec-json", "")
buf, contentType, err := buildTestResultsMultipartBody(resultFile.Name(), "rspec-json", "", nil)
require.NoError(t, err)

_, params, err := mime.ParseMediaType(contentType)
Expand Down
2 changes: 1 addition & 1 deletion internal/command/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ func uploadResults(ctx context.Context, apiClient *api.Client, cfg *config.Confi
return
}
fmt.Println("Buildkite Test Engine Client: Uploading test results to Test Engine")
if err := apiClient.UploadTestResults(ctx, cfg.UploadToken, testRunner.ResultFilePath(), format, testRunner.LocationPrefix()); err != nil {
if err := apiClient.UploadTestResults(ctx, cfg.UploadToken, testRunner.ResultFilePath(), format, testRunner.LocationPrefix(), cfg.UploadTags); err != nil {
fmt.Printf("Buildkite Test Engine Client: Failed to upload test results to Test Engine: %v\n", err)
}
}
Expand Down
2 changes: 2 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ type Config struct {
UploadFile string `json:"-"`
// UploadResults enables uploading test results to the Test Engine analytics API after each run.
UploadResults bool `json:"-"`
// UploadTags are key/value tags attached to the upload when sending test results.
UploadTags map[string]string `json:"-"`
// UploadToken is the token used by test collectors. From `BUILDKITE_ANALYTICS_TOKEN` if present, otherwise generated by `buildkite-agent oidc request-token`.
UploadToken string `json:"-"`
// SplitByExample is the flag to enable split the test by example.
Expand Down