Skip to content

Commit 2e5527b

Browse files
committed
initial commit
0 parents  commit 2e5527b

File tree

10 files changed

+883
-0
lines changed

10 files changed

+883
-0
lines changed

.github/workflows/test.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches: [ "main" ]
6+
pull_request:
7+
branches: [ "main" ]
8+
9+
jobs:
10+
11+
build:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Set up Go
17+
uses: actions/setup-go@v4
18+
with:
19+
go-version: '1.16'
20+
21+
- name: Test
22+
run: go test -v ./...
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"calls": [
3+
{
4+
"method": "POST",
5+
"url": "https://other.com",
6+
"reqBody": {
7+
"req3": "value"
8+
},
9+
"resBody": {
10+
"res3": "data"
11+
},
12+
"status": 200
13+
}
14+
]
15+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"calls": [
3+
{
4+
"method": "POST",
5+
"url": "https://test.com",
6+
"reqBody": {
7+
"req1": "value"
8+
},
9+
"resBody": {
10+
"res1": "data"
11+
},
12+
"status": 200
13+
},
14+
{
15+
"method": "POST",
16+
"url": "https://example.com",
17+
"reqBody": {
18+
"req2": "value"
19+
},
20+
"resBody": {
21+
"res2": "data"
22+
},
23+
"status": 200
24+
}
25+
]
26+
}

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module github.com/millotp/gocksnap
2+
3+
go 1.16
4+
5+
require github.com/h2non/gock v1.2.0

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
2+
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
3+
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
4+
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
5+
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
6+
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=

gocksnap.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package gocksnap
2+
3+
import (
4+
_ "embed"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
"sync"
13+
"testing"
14+
15+
"github.com/h2non/gock"
16+
)
17+
18+
const defaultSnapshotDirectory = "__snapshots__"
19+
20+
//go:embed index.html
21+
var indexHTML string
22+
23+
// Call represents a single HTTP call in the snapshot.
24+
type Call struct {
25+
// Method is the HTTP method of the call (GET, POST, etc.)
26+
Method string `json:"method"`
27+
28+
// URL is the full URL of the call.
29+
URL string `json:"url"`
30+
31+
// ReqBody is the request body of the call, if any.
32+
ReqBody json.RawMessage `json:"reqBody"`
33+
34+
// ResBody is the response body of the call, if any.
35+
ResBody json.RawMessage `json:"resBody"`
36+
37+
// Status is the HTTP status code of the response.
38+
Status int `json:"status"`
39+
}
40+
41+
// Snapshot holds the state of the snapshot being recorded, which can include multiple HTTP calls.
42+
type Snapshot struct {
43+
Calls []Call `json:"calls"`
44+
45+
// testName used to identify the snapshot file.
46+
testName string
47+
48+
// name is the name of the snapshot, used for identification in the test and in the file.
49+
name string
50+
51+
// updateMode indicates if the snapshot is in update mode or in test mode.
52+
updateMode bool
53+
54+
// mu is a mutex to protect access to the pending call and SSE connections.
55+
mu sync.Mutex
56+
57+
// pending is the current call that is being recorded.
58+
pending *CallPrompt
59+
60+
// sseConns is a map of client connections that are waiting for updates.
61+
sseConns map[chan string]struct{}
62+
}
63+
64+
// Finish
65+
func (g *Snapshot) Finish(t *testing.T) {
66+
t.Helper()
67+
68+
if !g.updateMode {
69+
if !gock.IsDone() {
70+
t.Fatalf("Snapshot '%s' is not complete. Some requests were not mocked.", g.name)
71+
}
72+
73+
return
74+
}
75+
76+
// Update snapshot
77+
data, err := json.MarshalIndent(g, "", " ")
78+
if err != nil {
79+
t.Fatalf("Failed to marshal snapshot '%s': %v", g.name, err)
80+
}
81+
82+
_ = os.MkdirAll(defaultSnapshotDirectory, 0o750)
83+
84+
err = os.WriteFile(g.file(), data, 0o600)
85+
if err != nil {
86+
t.Fatalf("Failed to save snapshot '%s': %v", g.name, err)
87+
}
88+
}
89+
90+
// file returns the path to the snapshot file.
91+
func (g *Snapshot) file() string {
92+
return filepath.Join(defaultSnapshotDirectory, strings.ReplaceAll(strings.ReplaceAll(g.testName+"-"+g.name, " ", "_"), "/", "_")+".json")
93+
}
94+
95+
// promptCall sends the current request to the UI for user interaction.
96+
func (g *Snapshot) promptCall(req *http.Request, existingCall *Call) *Call {
97+
var bodyRaw []byte
98+
if req.Body != nil {
99+
bodyRaw, _ = io.ReadAll(req.Body)
100+
}
101+
102+
g.mu.Lock()
103+
104+
fmt.Printf("Request: %s %s\n", req.Method, req.URL.String())
105+
106+
g.pending = &CallPrompt{
107+
Name: g.name,
108+
Call: Call{
109+
Method: req.Method,
110+
URL: req.URL.String(),
111+
ReqBody: bodyRaw,
112+
},
113+
ExistingCall: existingCall,
114+
finalCall: make(chan *Call, 1),
115+
}
116+
117+
// notify SSE clients
118+
for ch := range g.sseConns {
119+
select {
120+
case ch <- "pending":
121+
default:
122+
}
123+
}
124+
125+
g.mu.Unlock()
126+
127+
return <-g.pending.finalCall
128+
}
129+
130+
// MatchSnapshot creates a new snapshot for the current test.
131+
// If the snapshot file is not found, or if the environment variable UPDATE_TESTS is set to "true", it will spawn a web server to allow the user to interactively select responses for the recorded requests.
132+
// If the snapshot file is found, it will load the existing calls and register them with gock.
133+
// After all the calls are finished, the user should call the Finish method to save the snapshot / assert that all calls were mocked correctly.
134+
func MatchSnapshot(t *testing.T, snapshotName string) *Snapshot {
135+
t.Helper()
136+
137+
snapshot := &Snapshot{
138+
Calls: []Call{},
139+
testName: t.Name(),
140+
name: snapshotName,
141+
updateMode: os.Getenv("UPDATE_TESTS") == "true",
142+
sseConns: make(map[chan string]struct{}),
143+
}
144+
145+
var existingCalls []Call
146+
147+
_, err := os.Stat(snapshot.file())
148+
if os.IsNotExist(err) {
149+
// can't find snapshot file, so we are in update mode
150+
t.Logf("Snapshot '%s' not found, running in update mode\n", snapshot.file())
151+
snapshot.updateMode = true
152+
} else {
153+
// Load existing snapshot
154+
data, err := os.ReadFile(snapshot.file())
155+
if err != nil {
156+
t.Fatalf("Failed to open snapshot '%s': %v", snapshot.file(), err)
157+
}
158+
159+
err = json.Unmarshal(data, snapshot)
160+
if err != nil {
161+
t.Fatalf("Failed to unmarshal snapshot '%s': %v", snapshot.file(), err)
162+
}
163+
164+
if snapshot.updateMode {
165+
existingCalls = snapshot.Calls
166+
snapshot.Calls = make([]Call, 0)
167+
t.Logf("Updating existing snapshot '%s'\n", snapshot.file())
168+
}
169+
}
170+
171+
if snapshot.updateMode {
172+
addr, err := snapshot.startPromptServer()
173+
if err != nil {
174+
t.Fatalf("Failed to start prompt server for snapshot '%s': %v", snapshot.file(), err)
175+
}
176+
177+
openBrowser(addr)
178+
}
179+
180+
gock.Intercept()
181+
182+
if snapshot.updateMode {
183+
var existingCall *Call
184+
if len(existingCalls) > 0 {
185+
existingCall = &existingCalls[0]
186+
}
187+
188+
// clean up any existing mocks
189+
for _, mock := range gock.Pending() {
190+
mock.Disable()
191+
}
192+
193+
gock.Register(snapshot.newRecordMock(existingCall))
194+
gock.Observe(func(_ *http.Request, mock gock.Mock) {
195+
snapshot.Calls = append(snapshot.Calls, *mock.(*recordMocker).call)
196+
// load the next one
197+
existingCall = nil
198+
if len(snapshot.Calls) < len(existingCalls) {
199+
existingCall = &existingCalls[len(snapshot.Calls)]
200+
}
201+
202+
gock.Register(snapshot.newRecordMock(existingCall))
203+
})
204+
205+
return snapshot
206+
}
207+
208+
// Register existing calls into gock.
209+
for _, call := range snapshot.Calls {
210+
req := gock.NewRequest().URL(call.URL).JSON(call.ReqBody)
211+
req.Method = strings.ToUpper(call.Method)
212+
gock.Register(gock.NewMock(req, gock.NewResponse().Status(call.Status).JSON(call.ResBody)))
213+
}
214+
215+
t.Logf("Loaded snapshot '%s' with %d calls", snapshot.file(), len(snapshot.Calls))
216+
217+
return snapshot
218+
}

gocksnap_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package gocksnap
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"strings"
7+
"testing"
8+
9+
"github.com/h2non/gock"
10+
)
11+
12+
func TestSnapshot(t *testing.T) {
13+
defer gock.Off()
14+
15+
snapshot := MatchSnapshot(t, "works with multiple calls")
16+
17+
c := &http.Client{}
18+
resp, err := c.Post("https://test.com", "application/json", strings.NewReader(`{"req1": "value"}`))
19+
if err != nil {
20+
t.Fatalf("Failed to make request: %v", err)
21+
}
22+
23+
if resp == nil {
24+
t.Fatal("Response is nil")
25+
}
26+
defer resp.Body.Close()
27+
28+
body, err := io.ReadAll(resp.Body)
29+
if err != nil {
30+
t.Fatalf("Failed to read response body: %v", err)
31+
}
32+
if strings.TrimSpace(string(body)) != `{"res1":"data"}` {
33+
t.Fatalf("Unexpected response body: '%s'", body)
34+
}
35+
36+
resp, err = c.Post("https://example.com", "application/json", strings.NewReader(`{"req2": "value"}`))
37+
if err != nil {
38+
t.Fatalf("Failed to make second request: %v", err)
39+
}
40+
41+
if resp == nil {
42+
t.Fatal("Response is nil for second request")
43+
}
44+
defer resp.Body.Close()
45+
46+
body, err = io.ReadAll(resp.Body)
47+
if err != nil {
48+
t.Fatalf("Failed to read second response body: %v", err)
49+
}
50+
if strings.TrimSpace(string(body)) != `{"res2":"data"}` {
51+
t.Fatalf("Unexpected second response body: %s", body)
52+
}
53+
54+
snapshot.Finish(t)
55+
56+
snapshot = MatchSnapshot(t, "works with a second scenario")
57+
58+
resp, err = c.Post("https://other.com", "application/json", strings.NewReader(`{"req3": "value"}`))
59+
if err != nil {
60+
t.Fatalf("Failed to make request: %v", err)
61+
}
62+
63+
if resp == nil {
64+
t.Fatal("Response is nil")
65+
}
66+
defer resp.Body.Close()
67+
68+
body, err = io.ReadAll(resp.Body)
69+
if err != nil {
70+
t.Fatalf("Failed to read response body: %v", err)
71+
}
72+
if strings.TrimSpace(string(body)) != `{"res3":"data"}` {
73+
t.Fatalf("Unexpected response body: '%s'", body)
74+
}
75+
76+
snapshot.Finish(t)
77+
}

0 commit comments

Comments
 (0)