Skip to content

Commit 65b5995

Browse files
authored
Merge pull request #426 from buildkite/te-5134-add-tag-filters-option-to-bktec
[TE-5134] Add --tag-filters option to filter tests by markers
2 parents ecb46dd + fb712ca commit 65b5995

File tree

17 files changed

+339
-37
lines changed

17 files changed

+339
-37
lines changed

.buildkite/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ RUN gem install rspec cucumber base64
1212
RUN gem install bigdecimal -v 3.2.0
1313
RUN yarn global add jest
1414
RUN pip install pytest
15-
RUN pip install buildkite-test-collector==0.2.0
15+
RUN pip install buildkite-test-collector>=1.3.0
1616
RUN curl --proto '=https' --tlsv1.2 -fsSL https://static.pantsbuild.org/setup/get-pants.sh | bash -s -- --bin-dir /usr/local/bin
1717

1818
# Install curl, download bktec binary, make it executable, place it, and cleanup

.tool-versions

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
golang 1.25.2
22
nodejs 24.9.0
33
python 3.13.2
4+
uv 0.9.26

bin/setup

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ go install github.com/pact-foundation/pact-go/v2
99
go install gotest.tools/[email protected]
1010

1111
# Check if asdf is installed and being used for Go
12-
if command -v asdf &> /dev/null && asdf current golang &> /dev/null; then
12+
if command -v asdf &>/dev/null && asdf current golang &>/dev/null; then
1313
echo "🔄 Reshimming asdf golang..."
1414
asdf reshim golang
1515
fi
1616

1717
# download and install the required libraries.
1818
# TODO if pact-go check return non- zero then install it
19-
if ! pact-go check &> /dev/null; then
19+
if ! pact-go check &>/dev/null; then
2020
echo "🔄 Installing pact-go dependencies..."
2121
sudo pact-go -l DEBUG install
2222
else
@@ -28,8 +28,7 @@ echo "🛠️ Installing dependencies for sample projects..."
2828
pushd ./internal/runner/testdata || exit 1
2929
# if yarn is available, use it to install dependencies
3030
# otherwise, use npm
31-
if command -v yarn &> /dev/null
32-
then
31+
if command -v yarn &>/dev/null; then
3332
yarn install
3433
else
3534
npm install
@@ -68,7 +67,7 @@ else
6867
python -m venv .venv && source .venv/bin/activate
6968
fi
7069
pip install pytest
71-
pip install buildkite-test-collector==0.2.0
70+
pip install "buildkite-test-collector>=1.3.0"
7271
curl --proto '=https' --tlsv1.2 -fsSL https://static.pantsbuild.org/setup/get-pants.sh | bash
7372

7473
echo "💖 Everything is fantastic!"

cli.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,19 @@ var filesFlag = &cli.StringFlag{
115115
Sources: cli.EnvVars("BUILDKITE_TEST_ENGINE_FILES"),
116116
}
117117

118-
var testCommandFlag = &cli.StringFlag{
119-
Name: "test-command",
118+
var tagFiltersFlag = &cli.StringFlag{
119+
Name: "tag-filters",
120120
Category: "TEST RUNNER",
121-
Usage: "Test command",
122-
Sources: cli.EnvVars("BUILDKITE_TEST_ENGINE_TEST_CMD"),
123-
Destination: &cfg.TestCommand,
121+
Usage: "Tag filters to apply when selecting tests to run (currently only Pytest is supported)",
122+
Sources: cli.EnvVars("BUILDKITE_TEST_ENGINE_TAG_FILTERS"),
123+
Destination: &cfg.TagFilters,
124+
}
125+
126+
var testCommandFlag = &cli.StringFlag{
127+
Name: "test-command",
128+
Category: "TEST RUNNER",
129+
Usage: "Test command",
130+
Sources: cli.EnvVars("BUILDKITE_TEST_ENGINE_TEST_CMD"),
124131
}
125132

126133
var testFilePatternFlag = &cli.StringFlag{
@@ -267,6 +274,7 @@ var cliCommand = &cli.Command{
267274
Action: run,
268275
Flags: []cli.Flag{
269276
filesFlag,
277+
tagFiltersFlag,
270278
planIdentifierFlag,
271279
// Build Environment Flags
272280
organizationSlugFlag,
@@ -304,6 +312,7 @@ var cliCommand = &cli.Command{
304312
// we will remove these in future iterations.
305313

306314
filesFlag,
315+
tagFiltersFlag,
307316
// Dynamic Parallelism Flags
308317
maxParallelismFlag,
309318
targetTimeFlag,

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/buildkite/test-engine-client
22

3-
go 1.24
3+
go 1.24.0
44

55
toolchain go1.24.1
66

@@ -16,6 +16,7 @@ require (
1616
github.com/pact-foundation/pact-go/v2 v2.4.2
1717
github.com/stretchr/testify v1.11.1
1818
github.com/urfave/cli/v3 v3.6.2
19+
golang.org/x/mod v0.32.0
1920
)
2021

2122
require (

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5J
6161
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
6262
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
6363
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
64+
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
65+
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
6466
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
6567
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
6668
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=

internal/api/filter_tests.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,15 @@ type FilteredTestResponse struct {
2222
Tests []FilteredTest `json:"tests"`
2323
}
2424

25-
// FilterTests filters tests from the server. It returns a list of tests that need to be split by example.
26-
// Currently, it only filters tests that are slow.
25+
// FilterTests fetches test files from the server. It returns a list of test files that
26+
// need to be split by example.
27+
//
28+
// Currently, it only fetches tests file that are slow and test files that have tests
29+
// marked for skipping.
30+
//
31+
// The splitByExample flag is passed through to the server, which is false will only
32+
// return test files that contain skipped tests, while true will also return slow test
33+
// files.
2734
func (c Client) FilterTests(ctx context.Context, suiteSlug string, params FilterTestsParams) ([]FilteredTest, error) {
2835
url := fmt.Sprintf("%s/v2/analytics/organizations/%s/suites/%s/test_plan/filter_tests", c.ServerBaseUrl, c.OrganizationSlug, suiteSlug)
2936

@@ -33,7 +40,6 @@ func (c Client) FilterTests(ctx context.Context, suiteSlug string, params Filter
3340
URL: url,
3441
Body: params,
3542
}, &response)
36-
3743
if err != nil {
3844
return []FilteredTest{}, err
3945
}

internal/command/request_param.go

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ import (
1111
)
1212

1313
// createRequestParam generates the parameters needed for a test plan request.
14-
// For runners other than "rspec", it constructs the test plan parameters with all test files.
15-
// For the "rspec" runner, it filters the test files through the Test Engine API and splits the filtered files into examples.
14+
//
15+
// For the Rspec, Cucumber and Pytest runner, it fetches test files through the Test Engine API
16+
// that are slow or contain skipped tests. These files are then split into examples
17+
// The remaining files are sent as is.
18+
//
19+
// If tag filtering is enabled, all files are split into examples to support filtering.
20+
// Currently only the Pytest runner supports tag filtering.
1621
func createRequestParam(ctx context.Context, cfg *config.Config, files []string, client api.Client, runner TestRunner) (api.TestPlanParams, error) {
1722
testFiles := []plan.TestCase{}
1823
for _, file := range files {
@@ -50,10 +55,21 @@ func createRequestParam(ctx context.Context, cfg *config.Config, files []string,
5055
debug.Println("Splitting by example")
5156
}
5257

53-
// The SplitByExample flag indicates whether to filter slow files for splitting by example.
54-
// Regardless of the flag's state, the API will still filter other files that need to be split by example, such as those containing skipped tests.
55-
// Therefore, we must filter and split files even when SplitByExample is disabled.
56-
testParams, err := filterAndSplitFiles(ctx, cfg, client, testFiles, runner)
58+
var testParams api.TestPlanParamsTest
59+
var err error
60+
61+
// If tag filtering is enabled, we must split all files to allow to enable filtering.
62+
// Tag filtering is currently only supported for pytest.
63+
if cfg.TagFilters != "" && runner.Name() == "pytest" {
64+
testParams, err = splitAllFiles(testFiles, runner)
65+
} else {
66+
// The SplitByExample flag indicates whether to split slow files into examples.
67+
// Regardless of the flag's state, the API will still return other test files that need to
68+
// be split by example, such as those containing skipped tests.
69+
// Therefore, we must fetch and split files even when SplitByExample is disabled.
70+
testParams, err = filterAndSplitFiles(ctx, cfg, client, testFiles, runner)
71+
}
72+
5773
if err != nil {
5874
return api.TestPlanParams{}, err
5975
}
@@ -69,6 +85,26 @@ func createRequestParam(ctx context.Context, cfg *config.Config, files []string,
6985
}, nil
7086
}
7187

88+
// Splits all the test files into examples to support tag filtering.
89+
func splitAllFiles(files []plan.TestCase, runner TestRunner) (api.TestPlanParamsTest, error) {
90+
debug.Printf("Splitting all %d files", len(files))
91+
filePaths := make([]string, 0, len(files))
92+
for _, file := range files {
93+
filePaths = append(filePaths, file.Path)
94+
}
95+
96+
examples, err := runner.GetExamples(filePaths)
97+
if err != nil {
98+
return api.TestPlanParamsTest{}, fmt.Errorf("get examples: %w", err)
99+
}
100+
101+
debug.Printf("Got %d examples from all files", len(examples))
102+
103+
return api.TestPlanParamsTest{
104+
Examples: examples,
105+
}, nil
106+
}
107+
72108
// filterAndSplitFiles filters the test files through the Test Engine API and splits the filtered files into examples.
73109
// It returns the test plan parameters with the examples from the filtered files and the remaining files.
74110
// An error is returned if there is a failure in any of the process.

internal/command/run_test.go

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,6 @@ func TestCreateRequestParams(t *testing.T) {
693693
TestCommand: "rspec",
694694
},
695695
})
696-
697696
if err != nil {
698697
t.Errorf("createRequestParam() error = %v", err)
699698
}
@@ -778,7 +777,6 @@ func TestCreateRequestParams_NonRSpec(t *testing.T) {
778777
}
779778

780779
got, err := createRequestParam(context.Background(), &cfg, files, *client, r)
781-
782780
if err != nil {
783781
t.Errorf("createRequestParam() error = %v", err)
784782
}
@@ -838,7 +836,6 @@ func TestCreateRequestParams_PytestPants(t *testing.T) {
838836
}
839837

840838
got, err := createRequestParam(context.Background(), &cfg, files, *client, runner)
841-
842839
if err != nil {
843840
t.Errorf("createRequestParam() error = %v", err)
844841
}
@@ -935,7 +932,6 @@ func TestCreateRequestParams_NoFilteredFiles(t *testing.T) {
935932
TestCommand: "rspec",
936933
},
937934
})
938-
939935
if err != nil {
940936
t.Errorf("createRequestParam() error = %v", err)
941937
}
@@ -962,6 +958,122 @@ func TestCreateRequestParams_NoFilteredFiles(t *testing.T) {
962958
}
963959
}
964960

961+
func TestCreateRequestParams_WithTagFilters(t *testing.T) {
962+
cfg := config.Config{
963+
OrganizationSlug: "my-org",
964+
SuiteSlug: "my-suite",
965+
Identifier: "identifier",
966+
Parallelism: 2,
967+
Branch: "main",
968+
TestRunner: "pytest",
969+
TagFilters: "team:frontend",
970+
}
971+
972+
client := api.NewClient(api.ClientConfig{
973+
ServerBaseUrl: "example.com",
974+
})
975+
976+
files := []string{
977+
"../runner/testdata/pytest/failed_test.py",
978+
"../runner/testdata/pytest/test_sample.py",
979+
"../runner/testdata/pytest/spells/test_expelliarmus.py",
980+
}
981+
982+
got, err := createRequestParam(context.Background(), &cfg, files, *client, runner.Pytest{
983+
RunnerConfig: runner.RunnerConfig{
984+
TestCommand: "pytest",
985+
TagFilters: "team:frontend",
986+
},
987+
})
988+
if err != nil {
989+
t.Errorf("createRequestParam() error = %v", err)
990+
}
991+
992+
want := api.TestPlanParams{
993+
Identifier: "identifier",
994+
Parallelism: 2,
995+
Branch: "main",
996+
Runner: "pytest",
997+
Tests: api.TestPlanParamsTest{
998+
Examples: []plan.TestCase{
999+
{
1000+
Format: "example",
1001+
Identifier: "runner/testdata/pytest/test_sample.py::test_happy",
1002+
Name: "test_happy",
1003+
Path: "runner/testdata/pytest/test_sample.py::test_happy",
1004+
Scope: "runner/testdata/pytest/test_sample.py",
1005+
},
1006+
{
1007+
Format: "example",
1008+
Identifier: "runner/testdata/pytest/spells/test_expelliarmus.py::TestExpelliarmus::test_knocks_wand_out",
1009+
Name: "test_knocks_wand_out",
1010+
Path: "runner/testdata/pytest/spells/test_expelliarmus.py::TestExpelliarmus::test_knocks_wand_out",
1011+
Scope: "runner/testdata/pytest/spells/test_expelliarmus.py::TestExpelliarmus",
1012+
},
1013+
},
1014+
},
1015+
}
1016+
1017+
if diff := cmp.Diff(got, want); diff != "" {
1018+
t.Errorf("createRequestParam() diff (-got +want):\n%s", diff)
1019+
}
1020+
}
1021+
1022+
func TestCreateRequestParams_WithTagFilters_NonPytest(t *testing.T) {
1023+
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1024+
fmt.Fprint(w, `
1025+
{
1026+
"tests": []
1027+
}`)
1028+
}))
1029+
defer svr.Close()
1030+
1031+
cfg := config.Config{
1032+
OrganizationSlug: "my-org",
1033+
SuiteSlug: "my-suite",
1034+
Identifier: "identifier",
1035+
Parallelism: 2,
1036+
Branch: "main",
1037+
TestRunner: "rspec",
1038+
TagFilters: "team:frontend",
1039+
}
1040+
1041+
client := api.NewClient(api.ClientConfig{
1042+
ServerBaseUrl: svr.URL,
1043+
})
1044+
1045+
files := []string{
1046+
"testdata/rspec/spec/fruits/apple_spec.rb",
1047+
"testdata/rspec/spec/fruits/banana_spec.rb",
1048+
}
1049+
1050+
got, err := createRequestParam(context.Background(), &cfg, files, *client, runner.Rspec{
1051+
RunnerConfig: runner.RunnerConfig{
1052+
TestCommand: "rspec",
1053+
},
1054+
})
1055+
if err != nil {
1056+
t.Errorf("createRequestParam() error = %v", err)
1057+
}
1058+
1059+
want := api.TestPlanParams{
1060+
Identifier: "identifier",
1061+
Parallelism: 2,
1062+
Branch: "main",
1063+
Runner: "rspec",
1064+
Tests: api.TestPlanParamsTest{
1065+
Files: []plan.TestCase{
1066+
{Path: "testdata/rspec/spec/fruits/apple_spec.rb"},
1067+
{Path: "testdata/rspec/spec/fruits/banana_spec.rb"},
1068+
},
1069+
},
1070+
}
1071+
1072+
if diff := cmp.Diff(got, want); diff != "" {
1073+
t.Errorf("createRequestParam() diff (-got +want):\n%s", diff)
1074+
}
1075+
}
1076+
9651077
func TestSendMetadata(t *testing.T) {
9661078
originalVersion := version.Version
9671079
version.Version = "0.1.0"
@@ -1019,7 +1131,6 @@ func TestSendMetadata(t *testing.T) {
10191131
} else {
10201132
w.WriteHeader(http.StatusOK)
10211133
}
1022-
10231134
}))
10241135
defer svr.Close()
10251136

@@ -1070,7 +1181,6 @@ func TestRunTestsWithRetry_NoTestCases_Success(t *testing.T) {
10701181
failOnNoTests := false
10711182

10721183
testResult, err := runTestsWithRetry(testRunner, &testCases, maxRetries, []plan.TestCase{}, &timeline, true, failOnNoTests)
1073-
10741184
if err != nil {
10751185
t.Errorf("runTestsWithRetry(...) error = %v, want nil", err)
10761186
}

internal/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ type Config struct {
5252
DebugEnabled bool `json:"BUILDKITE_TEST_ENGINE_DEBUG_ENABLED"`
5353
// FailOnNoTests causes the client to exit with an error if no tests are assigned to the node
5454
FailOnNoTests bool `json:"BUILDKITE_TEST_ENGINE_FAIL_ON_NO_TESTS"`
55+
// TagFilters filters test examples by execution tags.
56+
TagFilters string `json:"BUILDKITE_TEST_ENGINE_TAG_FILTERS"`
5557
// errs is a map of environment variables name and the validation errors associated with them.
5658
errs InvalidConfigError
5759
}

0 commit comments

Comments
 (0)