Skip to content

Commit 8c3b4df

Browse files
authored
Add regexp assertions to responses (#28)
1 parent f9a0d73 commit 8c3b4df

File tree

5 files changed

+135
-1
lines changed

5 files changed

+135
-1
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,15 @@ Another flavour of JSON matching is to match only specific fields with [JSON Pat
205205
| $[0].dyn | "$dyn" |
206206
```
207207

208+
Response body can be matched with regular expression, named matches are treated as variables.
209+
210+
```gherkin
211+
And I should have "some-service" response with body, that matches regular expression
212+
"""
213+
"time"\s*:\s*"(?P<year>\d{4})
214+
"""
215+
```
216+
208217
```gherkin
209218
210219
Status can be defined with either phrase or numeric code.

_testdata/Dynamic.feature

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ Feature: Dynamic data is used in steps
2424
"id":"$user_id"
2525
"""
2626

27+
And I should have response with body, that matches regular expression
28+
"""
29+
"name"\s*:\s*"(?P<first_name>\w+)
30+
"""
31+
32+
And variable $first_name equals to "John"
33+
2734
# Creating an order for that user with $user_id.
2835
When I request HTTP endpoint with method "POST" and URI "/order/$user_id/?user_id=$user_id"
2936
And I request HTTP endpoint with header "X-UserId: $user_id"

_testdata/LocalClient.feature

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ Feature: HTTP Service
6767
"""
6868
"status":"failed"
6969
"""
70+
And I should have other responses with body, that matches regular expression
71+
"""
72+
"error"\s*:\s*"(?P<errorMessage>[A-Za-z]+)"
73+
"""
74+
75+
And variable $errorMessage equals to "foo"
76+
7077

7178
And I should have other responses with header "Content-Type: application/json"
7279

local_client.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"net/url"
1414
"os"
1515
"path/filepath"
16+
"regexp"
1617
"sort"
1718
"strconv"
1819
"strings"
@@ -216,6 +217,7 @@ func (l *LocalClient) RegisterSteps(s *godog.ScenarioContext) {
216217
s.Step(`^I should have(.*) response with body, that matches JSON from file$`, l.iShouldHaveResponseWithBodyThatMatchesJSONFromFile)
217218
s.Step(`^I should have(.*) response with body, that matches JSON$`, l.iShouldHaveResponseWithBodyThatMatchesJSON)
218219
s.Step(`^I should have(.*) response with body, that matches JSON paths$`, l.iShouldHaveResponseWithBodyThatMatchesJSONPaths)
220+
s.Step(`^I should have(.*) response with body, that matches regular expression$`, l.iShouldHaveResponseWithBodyThatMatchesRegexp)
219221

220222
s.Step(`^I should have(.*) other responses with status "([^"]*)"$`, l.iShouldHaveOtherResponsesWithStatus)
221223
s.Step(`^I should have(.*) other responses with header "([^"]*): ([^"]*)"$`, l.iShouldHaveOtherResponsesWithHeader)
@@ -226,6 +228,7 @@ func (l *LocalClient) RegisterSteps(s *godog.ScenarioContext) {
226228
s.Step(`^I should have(.*) other responses with body, that matches JSON$`, l.iShouldHaveOtherResponsesWithBodyThatMatchesJSON)
227229
s.Step(`^I should have(.*) other responses with body, that matches JSON from file$`, l.iShouldHaveOtherResponsesWithBodyThatMatchesJSONFromFile)
228230
s.Step(`^I should have(.*) other responses with body, that matches JSON paths$`, l.iShouldHaveOtherResponsesWithBodyThatMatchesJSONPaths)
231+
s.Step(`^I should have(.*) other responses with body, that matches regular expression$`, l.iShouldHaveOtherResponsesWithBodyThatMatchesRegexp)
229232

230233
s.After(l.afterScenario)
231234
}
@@ -767,6 +770,99 @@ func (l *LocalClient) iShouldHaveResponseWithBodyThatContains(ctx context.Contex
767770
})
768771
}
769772

773+
func (l *LocalClient) iShouldHaveResponseWithBodyThatMatchesRegexp(ctx context.Context, service, bodyDoc string) (context.Context, error) {
774+
ctx = l.VS.PrepareContext(ctx)
775+
776+
return l.expectResponse(ctx, service, func(c *httpmock.Client) error {
777+
return c.ExpectResponseBodyCallback(func(received []byte) error {
778+
return l.matches(ctx, received, bodyDoc)
779+
})
780+
})
781+
}
782+
783+
func (l *LocalClient) matches(ctx context.Context, received []byte, pattern string) error {
784+
capture, matched, err := l.extractNamedMatches(received, pattern)
785+
if err != nil {
786+
return err
787+
}
788+
789+
if !matched {
790+
return augmentBodyErr(ctx, fmt.Errorf("%w %q in %q", errDoesNotContain, pattern, received))
791+
}
792+
793+
_, vs := l.VS.Vars(ctx)
794+
795+
for k, v := range capture {
796+
k := "$" + k
797+
798+
_, err := l.varCollected(vs, k, string(v))
799+
if err != nil {
800+
return augmentBodyErr(ctx, err)
801+
}
802+
}
803+
804+
return nil
805+
}
806+
807+
func (l *LocalClient) extractNamedMatches(data []byte, pattern string) (map[string][]byte, bool, error) {
808+
re, err := regexp.Compile(pattern)
809+
if err != nil {
810+
return nil, false, err
811+
}
812+
813+
indices := re.FindSubmatchIndex(data)
814+
if indices == nil {
815+
return nil, false, nil // no full match
816+
}
817+
818+
names := re.SubexpNames()
819+
result := make(map[string][]byte, len(names)-1)
820+
821+
// i=0 → full match
822+
// i≥1 → capture groups
823+
for i, name := range names {
824+
if i == 0 || name == "" {
825+
continue // skip whole match and unnamed groups
826+
}
827+
828+
start, end := indices[i*2], indices[i*2+1]
829+
if start == -1 {
830+
result[name] = nil // optional group didn't match
831+
832+
continue
833+
}
834+
835+
result[name] = data[start:end] // zero-copy []byte slice
836+
}
837+
838+
return result, true, nil
839+
}
840+
841+
func (l *LocalClient) varCollected(vs *shared.Vars, s string, v interface{}) (bool, error) {
842+
if vs == nil || !vs.IsVar(s) {
843+
return false, nil
844+
}
845+
846+
if n, ok := v.(json.Number); ok {
847+
v = shared.DecodeJSONNumber(n)
848+
} else if f, ok := v.(float64); ok && f == float64(int64(f)) {
849+
v = int64(f)
850+
}
851+
852+
fv, found := vs.Get(s)
853+
if !found {
854+
vs.Set(s, v)
855+
856+
return true, nil
857+
}
858+
859+
if fv != v {
860+
return false, fmt.Errorf("unexpected variable %s value, expected %v, received %v", s, fv, v)
861+
}
862+
863+
return false, nil
864+
}
865+
770866
func (l *LocalClient) iShouldHaveOtherResponsesWithBodyThatContains(ctx context.Context, service, bodyDoc string) (context.Context, error) {
771867
ctx = l.VS.PrepareContext(ctx)
772868

@@ -865,6 +961,16 @@ func (l *LocalClient) iShouldHaveOtherResponsesWithBodyThatMatchesJSONPaths(ctx
865961
})
866962
}
867963

964+
func (l *LocalClient) iShouldHaveOtherResponsesWithBodyThatMatchesRegexp(ctx context.Context, service, bodyDoc string) (context.Context, error) {
965+
ctx = l.VS.PrepareContext(ctx)
966+
967+
return l.expectResponse(ctx, service, func(c *httpmock.Client) error {
968+
return c.ExpectOtherResponsesBodyCallback(func(received []byte) error {
969+
return l.matches(ctx, received, bodyDoc)
970+
})
971+
})
972+
}
973+
868974
func (l *LocalClient) iShouldHaveOtherResponsesWithBodyThatMatchesJSONFromFile(ctx context.Context, service, filePath string) (context.Context, error) {
869975
ctx = l.VS.PrepareContext(ctx)
870976

local_client_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import (
1010

1111
"github.com/bool64/httpmock"
1212
"github.com/cucumber/godog"
13-
httpsteps "github.com/godogx/httpsteps"
13+
"github.com/godogx/httpsteps"
14+
"github.com/godogx/vars"
1415
"github.com/stretchr/testify/assert"
1516
"github.com/stretchr/testify/require"
1617
)
@@ -26,6 +27,7 @@ func TestLocal_RegisterSteps(t *testing.T) {
2627
concurrencyLevel := 5
2728
setExpectations(mock, concurrencyLevel)
2829

30+
vs := vars.Steps{}
2931
local := httpsteps.NewLocalClient(srvURL, func(client *httpmock.Client) {
3032
client.Headers = map[string]string{
3133
"X-Foo": "bar",
@@ -36,6 +38,7 @@ func TestLocal_RegisterSteps(t *testing.T) {
3638

3739
suite := godog.TestSuite{
3840
ScenarioInitializer: func(s *godog.ScenarioContext) {
41+
vs.Register(s)
3942
local.RegisterSteps(s)
4043
},
4144
Options: &godog.Options{
@@ -226,10 +229,12 @@ func TestLocal_RegisterSteps_dynamic(t *testing.T) {
226229
}))
227230
defer srv.Close()
228231

232+
vs := vars.Steps{}
229233
local := httpsteps.NewLocalClient(srv.URL)
230234

231235
suite := godog.TestSuite{
232236
ScenarioInitializer: func(s *godog.ScenarioContext) {
237+
vs.Register(s)
233238
local.RegisterSteps(s)
234239
},
235240
Options: &godog.Options{

0 commit comments

Comments
 (0)