Skip to content

Commit 3d3accb

Browse files
committed
feat: add template route test generator
1 parent da86ace commit 3d3accb

11 files changed

Lines changed: 739 additions & 129 deletions

File tree

cmd/muxt/generate.go

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"bytes"
5+
"errors"
56
"fmt"
67
"io"
78
"log"
@@ -44,14 +45,34 @@ func generateCommand(workingDirectory string, args []string, getEnv func(string)
4445
if err != nil {
4546
return err
4647
}
47-
s, err := muxt.TemplateRoutesFile(workingDirectory, log.New(stdout, "", 0), config)
48+
49+
if config.Tests {
50+
if buf, err := os.ReadFile(filepath.Join(workingDirectory, config.TestsFileName)); err != nil && !errors.Is(err, os.ErrNotExist) {
51+
return err
52+
} else if err == nil {
53+
config.PreviousTests = string(buf)
54+
}
55+
}
56+
57+
result, err := muxt.Generate(workingDirectory, log.New(stdout, "", 0), config)
4858
if err != nil {
4959
return err
5060
}
5161
var sb bytes.Buffer
5262
writeCodeGenerationComment(&sb)
53-
sb.WriteString(s)
54-
return os.WriteFile(filepath.Join(workingDirectory, config.OutputFileName), sb.Bytes(), 0o644)
63+
sb.WriteString(result.TemplateRoutes)
64+
65+
if err := os.WriteFile(filepath.Join(workingDirectory, config.OutputFileName), sb.Bytes(), 0o644); err != nil {
66+
return err
67+
}
68+
69+
if config.Tests {
70+
if err := os.WriteFile(filepath.Join(workingDirectory, config.TestsFileName), []byte(result.TemplateRoutesTest), 0o644); err != nil {
71+
return err
72+
}
73+
}
74+
75+
return nil
5576
}
5677

5778
func writeCodeGenerationComment(w io.StringWriter) {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
muxt generate --tests --template-data-type=Data
2+
3+
cat template_routes.go
4+
stdout '// muxt version: \(devel\)'
5+
6+
cat template_routes_test.go
7+
8+
exec go test -cover -v
9+
stdout '--- PASS: TestTemplateRoutes/ReadIndex'
10+
11+
-- template.gohtml --
12+
{{define "GET /" }}
13+
<!DOCTYPE html>
14+
<html lang="en">
15+
<head>
16+
<title>Hello</title>
17+
</head>
18+
<body>
19+
<h1>Hello, world!</h1>
20+
</body>
21+
</html>
22+
{{end}}
23+
24+
-- go.mod --
25+
module server
26+
27+
go 1.24
28+
-- template.go --
29+
package server
30+
31+
import (
32+
"embed"
33+
"html/template"
34+
)
35+
36+
//go:embed *.gohtml
37+
var formHTML embed.FS
38+
39+
var templates = template.Must(template.ParseFS(formHTML, "*"))

example/hypertext/template_routes.go

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

example/hypertext/template_routes_test.go

Lines changed: 164 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -17,64 +17,171 @@ import (
1717
"github.com/crhntr/muxt/example/hypertext/internal/fake"
1818
)
1919

20-
func TestRoutes(t *testing.T) {
21-
for _, tt := range []domtest.Case[*testing.T, *fake.Backend]{
22-
{
23-
Name: "when the row edit form is submitted",
24-
Given: domtest.GivenPtr(func(t *testing.T, f *fake.Backend) {
25-
f.SubmitFormEditRowReturns(hypertext.Row{ID: 1, Name: "a", Value: 97}, nil)
26-
}),
27-
When: func(t *testing.T) *http.Request {
28-
req := httptest.NewRequest(http.MethodPatch, hypertext.TemplateRoutePaths{}.SubmitFormEditRow(1), strings.NewReader(url.Values{"count": []string{"5"}}.Encode()))
29-
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
30-
return req
31-
},
32-
Then: func(t *testing.T, res *http.Response, f *fake.Backend) {
33-
assert.Equal(t, http.StatusOK, res.StatusCode)
34-
35-
domtest.Fragment(atom.Tbody, func(t *testing.T, fragment spec.DocumentFragment, _ *fake.Backend) {
36-
require.Equal(t, 1, fragment.ChildElementCount())
37-
tdList := fragment.QuerySelectorAll(`tr td`)
38-
require.Equal(t, 2, tdList.Length())
39-
require.Equal(t, "a", tdList.Item(0).TextContent())
40-
require.Equal(t, "97", tdList.Item(1).TextContent())
41-
})(t, res, f)
42-
43-
if assert.Equal(t, 1, f.SubmitFormEditRowCallCount()) {
44-
id, form := f.SubmitFormEditRowArgsForCall(0)
45-
require.EqualValues(t, 1, id)
46-
require.Equal(t, hypertext.EditRow{Value: 5}, form)
47-
}
48-
},
20+
// TemplateRoutePaths is a local alias because the muxt generate expects to write the tests in the package scope.
21+
type TemplateRoutePaths = hypertext.TemplateRoutePaths
22+
23+
func TestTemplateRoutes(t *testing.T) {
24+
// The Given, When, Then, and Case structures and the runCase function are only generated once.
25+
// You may add fields to any structure. Do not alter the signature of any Given, When, or Then function on Case.
26+
// You may edit the body of runCase (and the for case loop body).
27+
//
28+
// Consider if you want your collaborator test seam to be RoutesReceiver or your interface implementation's
29+
// collaborators. This generated test function works well either way. If you use RoutesReceiver as a seam, consider
30+
// using a mock generator like https://pkg.go.dev/github.com/maxbrunsfeld/counterfeiter/v6 or https://pkg.go.dev/github.com/ryanmoran/faux
31+
// If you decide to cover your RoutesReceiver testing with this test function. Add the receiver's collaborator
32+
// test doubles to the Given and Then structures so you can configure and make assertions in the respective
33+
// Given and Then test hooks for each case.
34+
type (
35+
// Given is the scope used for setting up test case collaborators.
36+
Given struct {
37+
receiver *fake.Backend
38+
}
39+
40+
// When is the scope used to create HTTP Requests. It is unlikely you will need to add additional fields.
41+
When struct{}
42+
43+
// Then is the scope used for test case assertions. It will likely have collaborator test doubles.
44+
Then struct {
45+
receiver *fake.Backend
46+
}
47+
48+
Case struct {
49+
// The Name, by default a generated identifier, you may change this.
50+
Name string
51+
52+
// The Template field is the route template being tested. It is used by the test generator to detect
53+
// which templates are being tested. Do not change this.
54+
Template string
55+
56+
// The "Given" function MAY set up collaborators.
57+
// The code generator does not add this field in newly generated test cases.
58+
Given func(t *testing.T, given Given)
59+
60+
// The "When" function MUST set up an HTTP Request.
61+
// The generated function will call httptest.NewRequest using the appropriate method and
62+
// the generated TemplateRoutePaths path constructor method.
63+
When func(t *testing.T, when When) *http.Request
64+
65+
// The "Then" function MAY make assertions on response or any configured collaborators.
66+
// The generated function will assert that the response.StatusCode matches the expected status code.
67+
//
68+
// Consider using https://pkg.go.dev/github.com/stretchr/testify for assertions
69+
// and https://pkg.go.dev/github.com/crhntr/dom/domtest for interacting with the HTML body.
70+
Then func(t *testing.T, then Then, response *http.Response)
71+
}
72+
)
73+
74+
runCase := func(t *testing.T, tc Case) {
75+
if tc.When == nil {
76+
t.Fatal("test case field When must not be nil")
77+
}
78+
if tc.Then == nil {
79+
t.Fatal("test case field Then must not be nil")
80+
}
81+
if tc.Template == "" {
82+
t.Fatal("test case field Template must not be empty")
83+
}
84+
85+
// If you need to do universal setup of your receiver, do that here.
86+
87+
receiver := new(fake.Backend)
88+
89+
mux := http.NewServeMux()
90+
hypertext.TemplateRoutes(mux, receiver)
91+
if tc.Given != nil {
92+
tc.Given(t, Given{
93+
receiver: receiver,
94+
})
95+
}
96+
request := tc.When(t, When{})
97+
recorder := httptest.NewRecorder()
98+
mux.ServeHTTP(recorder, request)
99+
100+
// If you want to do universal assertions of all your endpoints, consider writing a helper function
101+
// and calling it here.
102+
103+
if tc.Then != nil {
104+
tc.Then(t, Then{
105+
receiver: receiver,
106+
}, recorder.Result())
107+
}
108+
}
109+
110+
for _, tc := range []Case{{
111+
Name: "SubmitFormEditRow",
112+
Template: "PATCH /fruits/{id} SubmitFormEditRow(id, form)",
113+
Given: func(t *testing.T, given Given) {
114+
given.receiver.SubmitFormEditRowReturns(hypertext.Row{ID: 1, Name: "a", Value: 97}, nil)
115+
},
116+
When: func(t *testing.T, when When) *http.Request {
117+
body := strings.NewReader(url.Values{"count": []string{"5"}}.Encode())
118+
request := httptest.NewRequest("PATCH", TemplateRoutePaths{}.SubmitFormEditRow(1), body)
119+
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
120+
return request
121+
},
122+
Then: func(t *testing.T, then Then, response *http.Response) {
123+
require.Equal(t, http.StatusOK, response.StatusCode)
124+
125+
domtest.Fragment(atom.Tbody, func(t *testing.T, fragment spec.DocumentFragment, _ *fake.Backend) {
126+
require.Equal(t, 1, fragment.ChildElementCount())
127+
tdList := fragment.QuerySelectorAll(`tr td`)
128+
require.Equal(t, 2, tdList.Length())
129+
require.Equal(t, "a", tdList.Item(0).TextContent())
130+
require.Equal(t, "97", tdList.Item(1).TextContent())
131+
})(t, response, then.receiver)
132+
133+
if assert.Equal(t, 1, then.receiver.SubmitFormEditRowCallCount()) {
134+
id, form := then.receiver.SubmitFormEditRowArgsForCall(0)
135+
require.EqualValues(t, 1, id)
136+
require.Equal(t, hypertext.EditRow{Value: 5}, form)
137+
}
138+
},
139+
}, {
140+
Name: "GetFormEditRow",
141+
Template: "GET /fruits/{id}/edit GetFormEditRow(id)",
142+
Given: func(t *testing.T, given Given) {
143+
given.receiver.GetFormEditRowReturns(hypertext.Row{ID: 1, Name: "a", Value: 97}, nil)
144+
},
145+
When: func(t *testing.T, when When) *http.Request {
146+
request := httptest.NewRequest("GET", TemplateRoutePaths{}.GetFormEditRow(1), nil)
147+
return request
148+
},
149+
Then: func(t *testing.T, then Then, response *http.Response) {
150+
require.Equal(t, http.StatusOK, response.StatusCode)
151+
152+
domtest.Fragment(atom.Tbody, func(t *testing.T, fragment spec.DocumentFragment, _ *fake.Backend) {
153+
require.Equal(t, 1, fragment.ChildElementCount())
154+
tdList := fragment.QuerySelectorAll(`tr td`)
155+
require.Equal(t, 2, tdList.Length())
156+
require.Equal(t, "a", tdList.Item(0).TextContent())
157+
158+
input := tdList.Item(1).QuerySelector(`input[name='count']`)
159+
require.Equal(t, input.GetAttribute("value"), "97")
160+
})(t, response, then.receiver)
161+
},
162+
}, {
163+
Name: "ReadHelp",
164+
Template: "GET /help",
165+
When: func(t *testing.T, when When) *http.Request {
166+
request := httptest.NewRequest("GET", TemplateRoutePaths{}.ReadHelp(), nil)
167+
return request
168+
},
169+
Then: func(t *testing.T, then Then, response *http.Response) {
170+
require.Equal(t, http.StatusOK, response.StatusCode)
171+
},
172+
}, {
173+
Name: "List",
174+
Template: "GET /{$} List(ctx)",
175+
When: func(t *testing.T, when When) *http.Request {
176+
request := httptest.NewRequest("GET", TemplateRoutePaths{}.List(), nil)
177+
return request
49178
},
50-
{
51-
Name: "when the row edit form is requested",
52-
Given: domtest.GivenPtr(func(t *testing.T, f *fake.Backend) {
53-
f.GetFormEditRowReturns(hypertext.Row{ID: 1, Name: "a", Value: 97}, nil)
54-
}),
55-
When: func(t *testing.T) *http.Request {
56-
return httptest.NewRequest(http.MethodGet, hypertext.TemplateRoutePaths{}.GetFormEditRow(1), nil)
57-
},
58-
Then: func(t *testing.T, res *http.Response, f *fake.Backend) {
59-
assert.Equal(t, http.StatusOK, res.StatusCode)
60-
61-
domtest.Fragment(atom.Tbody, func(t *testing.T, fragment spec.DocumentFragment, _ *fake.Backend) {
62-
t.Log(fragment)
63-
require.Equal(t, 1, fragment.ChildElementCount())
64-
tdList := fragment.QuerySelectorAll(`tr td`)
65-
require.Equal(t, 2, tdList.Length())
66-
require.Equal(t, "a", tdList.Item(0).TextContent())
67-
68-
input := tdList.Item(1).QuerySelector(`input[name='count']`)
69-
require.Equal(t, input.GetAttribute("value"), "97")
70-
})(t, res, f)
71-
},
179+
Then: func(t *testing.T, then Then, response *http.Response) {
180+
if expected, got := http.StatusOK, response.StatusCode; expected != got {
181+
t.Errorf("unexpected status code: got %d expected %d", got, expected)
182+
}
72183
},
73-
} {
74-
t.Run(tt.Name, tt.Run(func(f *fake.Backend) http.Handler {
75-
mux := http.NewServeMux()
76-
hypertext.TemplateRoutes(mux, f)
77-
return mux
78-
}))
184+
}} {
185+
t.Run(tc.Name, func(t *testing.T) { runCase(t, tc) })
79186
}
80187
}

example/hypertext/templates.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import (
55
"html/template"
66
)
77

8-
//go:generate go run ../../cmd/muxt generate --receiver-type Backend --receiver-type-package github.com/crhntr/muxt/example --routes-func TemplateRoutes
8+
//go:generate go run ../../cmd/muxt generate --tests --receiver-type Backend --receiver-type-package github.com/crhntr/muxt/example --routes-func TemplateRoutes
99
//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate
1010
//counterfeiter:generate -o internal/fake/routes_receiver.go -fake-name Backend . RoutesReceiver
1111

internal/configuration/generate.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import (
55
"fmt"
66
"go/token"
77
"io"
8+
"os"
89
"path/filepath"
10+
"slices"
11+
"strings"
912

1013
"github.com/crhntr/muxt/internal/muxt"
1114
)
@@ -36,6 +39,9 @@ This function also receives an argument with a type matching the name given by r
3639
templateRoutePathsType = "template-route-paths-type"
3740
templateRoutePathsTypeHelp = `The type name for the type with path constructor helper methods.`
3841

42+
testsFlagName = "tests"
43+
testsFlagHelp = "generate a test file for the routes function"
44+
3945
errIdentSuffix = " value must be a well-formed Go identifier"
4046
)
4147

@@ -67,6 +73,11 @@ func NewRoutesFileConfiguration(args []string, stderr io.Writer) (muxt.RoutesFil
6773
if g.OutputFileName != "" && filepath.Ext(g.OutputFileName) != ".go" {
6874
return muxt.RoutesFileConfiguration{}, fmt.Errorf("output filename must use .go extension")
6975
}
76+
77+
if g.Tests {
78+
g.TestsFileName = strings.TrimSuffix(g.OutputFileName, ".go") + "_test.go"
79+
}
80+
7081
return g, nil
7182
}
7283

@@ -80,5 +91,10 @@ func RoutesFileConfigurationFlagSet(g *muxt.RoutesFileConfiguration) *flag.FlagS
8091
flagSet.StringVar(&g.ReceiverInterface, receiverInterfaceName, muxt.DefaultReceiverInterfaceName, receiverInterfaceNameHelp)
8192
flagSet.StringVar(&g.TemplateDataType, templateDataType, muxt.DefaultTemplateDataTypeName, templateDataTypeHelp)
8293
flagSet.StringVar(&g.TemplateRoutePathsTypeName, templateRoutePathsType, muxt.DefaultTemplateRoutePathsTypeName, templateRoutePathsTypeHelp)
94+
95+
if features, ok := os.LookupEnv("MUXT_PRE_RELEASE_FEATURES"); ok && slices.Contains(strings.Fields(features), "generate-tests") {
96+
flagSet.BoolVar(&g.Tests, testsFlagName, false, testsFlagHelp)
97+
}
98+
8399
return flagSet
84100
}

0 commit comments

Comments
 (0)