Part 13 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-13.
So far, every rule in TaskFlow was checked by sending curl requests by hand. After twelve parts there are many rules, and a change in one part can break a rule from another, for example a policy change that lets Ben delete Ana's tasks. This part adds tests that send the same requests and check the answers, so one go test run checks the rules again after any change.
Start from the Gates & Policies code:
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-12
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
A database for the tests
The tests need a database of their own. Each feature test starts from empty tables, by rolling the migrations back and running them again, and on the dev database that would wipe Ana, her tasks and everything else, so the tests get their own database, taskflow_test:
$ docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test"
CREATE DATABASE
The tests also need settings that point at that database. Goravel reads .env.testing instead of .env when go test gets --env=.env.testing. It starts as a copy of .env.example:
$ cp .env.example .env.testing
These lines change:
.env.testing
APP_NAME=Goravel
-APP_ENV=local
-APP_KEY=
+APP_ENV=testing
+APP_KEY=2Nr9bpCIal8Dj0qYE7IviHcYcwzmVqoh
APP_DEBUG=true
# ... 8 lines not shown
APP_PORT=3000
-JWT_SECRET=
+JWT_SECRET=xRQKZ4WHIu8bIHANdQVnskdTsa47cya8
DB_CONNECTION=postgres
DB_HOST=127.0.0.1
DB_PORT=5433
-DB_DATABASE=taskflow
+DB_DATABASE=taskflow_test
DB_USERNAME=goravel
DB_PASSWORD=secret
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=
REDIS_PORT=6380
+
+CACHE_STORE=memory
+HASH_ROUNDS=4
The two new settings do nothing yet, since config/cache.go always uses Redis and config/hashing.go always uses 12 rounds. Both now read their value from the env and keep the old value as the default, so only the tests get the new settings:
config/cache.go
// ... 9 lines not shown
config := facades.Config()
config.Add("cache", map[string]any{
- "default": "redis",
+ "default": config.Env("CACHE_STORE", "redis"),
// Cache Stores
// ... 27 lines not shown
config/hashing.go
// ... 23 lines not shown
// hashing time versus attacker time are diminishing, so choose your cost factor wisely.
"bcrypt": map[string]any{
- "rounds": 12,
+ "rounds": config.Env("HASH_ROUNDS", 12),
},
// ... 12 lines not shown
-
CACHE_STORE=memorykeeps the tests' cache in memory. The tests empty the cache before each test, and with Redis that would empty the whole Redis server, including the dev app's logged-out tokens and login counts. -
HASH_ROUNDS=4makes bcrypt cheap. Every test user and every login hashes a password, and at the usual 12 rounds the suite takes about 6 seconds instead of 1.
.env.testing goes into git, so a fresh clone can run the tests once the database exists. Its APP_KEY and JWT_SECRET are made up, and any 32 characters work, so nothing in it is a real secret. .gitignore skips every .env.* file except .env.example, so add an exception:
.gitignore
.idea
.vscode
.DS_Store
.env
.env.*
!.env.example
+!.env.testing
storage
tmp
A test case that boots the app
A test needs the app started the way main.go starts it, with its config, database and routes. Goravel's Testing package does that. Lite only installs the facades picked when the project is created, and Installation & Project Architecture didn't pick Testing, so install it now with package:install:
$ go run . artisan package:install Testing
INFO Testing depends on Log, Cache, Schema, Orm, Hash, Http, RateLimiter, Session, Storage, Validation, View, Route, they will be installed simultaneously
SUCCESS Facade Testing installed successfully
The packages Testing depends on are already installed, so it only adds these:
-
tests/test_case.go, with theTestCasestruct every test embeds. -
tests/feature/example_test.go, a placeholder test to delete. -
&testing.ServiceProvider{}inbootstrap/providers.go. It sets up whatRefreshDatabaseandHttpneed. -
app/facades/testing.go, the Testing facade, and testify ingo.mod.
The new test case boots the app in init. The feature tests wipe every table, so a check there stops the run unless the database is a test database:
tests/test_case.go
package tests
import (
+ "strings"
+
"github.com/goravel/framework/testing"
+ "taskflow/app/facades"
"taskflow/bootstrap"
)
func init() {
bootstrap.Boot()
+
+ if !strings.HasSuffix(facades.Config().GetString("database.connections.postgres.database"), "_test") {
+ panic("the tests drop every table, so they only run on a database whose name ends in _test: go test ./tests/... --env=.env.testing")
+ }
}
type TestCase struct {
testing.TestCase
}
- Go runs
initonce in each test package that importstaskflow/tests, before its tests.bootstrap.Boot()is the callmain.gomakes, so the tests run the real app. - The check reads the database name from the config. If the name doesn't end in
_test, the run stops with the guard's message. That catches a plaingo testwithout--env, and an.env.testingthat still saystaskflow. - Embedding Goravel's
testing.TestCasegives every testHttpfor requests andRefreshDatabasefor the empty tables.
Unit tests for the custom rules
A unit test checks one piece of code on its own, without the database or a request. The custom rules fit that well, because a rule's Passes gets a value and returns true or false. They are MaxBytes from Register & Login with JWT and FutureDate from Validation with Form Requests. make:test creates their test file:
$ go run . artisan make:test unit/RulesTest
SUCCESS Test created successfully
It writes a testify suite, a struct whose methods are the tests:
tests/unit/rules_test.go
package unit
import (
"testing"
"github.com/stretchr/testify/suite"
"taskflow/tests"
)
type RulesTestSuite struct {
suite.Suite
tests.TestCase
}
func TestRulesTestSuite(t *testing.T) {
suite.Run(t, new(RulesTestSuite))
}
// SetupTest will run before each test in the suite.
func (s *RulesTestSuite) SetupTest() {
}
// TearDownTest will run after each test in the suite.
func (s *RulesTestSuite) TearDownTest() {
}
func (s *RulesTestSuite) TestIndex() {
// TODO
}
-
RulesTestSuiteembedssuite.Suite, which gives it testify's checks likes.Equal, andtests.TestCasefrom the last section. -
TestRulesTestSuiteis the only functiongo testitself runs. Itssuite.Runthen calls every method on the struct whose name starts withTest. -
SetupTestandTearDownTestrun before and after each of those methods, andTestIndexis a placeholder.
The rules need no setup, so the file becomes one test per rule:
tests/unit/rules_test.go
package unit
import (
"context"
"strings"
"testing"
"github.com/goravel/framework/support/carbon"
"github.com/stretchr/testify/suite"
"taskflow/app/rules"
"taskflow/tests"
)
type RulesTestSuite struct {
suite.Suite
tests.TestCase
}
func TestRulesTestSuite(t *testing.T) {
suite.Run(t, new(RulesTestSuite))
}
func (s *RulesTestSuite) TestMaxBytes() {
cases := []struct {
name string
value any
passes bool
}{
{"72 bytes", strings.Repeat("a", 72), true},
{"73 bytes", strings.Repeat("a", 73), false},
{"18 emoji are 72 bytes", strings.Repeat("🔒", 18), true},
{"19 emoji are 76 bytes", strings.Repeat("🔒", 19), false},
{"not a string", 12345, false},
}
rule := &rules.MaxBytes{}
for _, c := range cases {
s.Equal(c.passes, rule.Passes(context.Background(), nil, c.value, "72"), c.name)
}
}
func (s *RulesTestSuite) TestFutureDate() {
cases := []struct {
name string
value any
passes bool
}{
{"tomorrow", carbon.Now().AddDay().ToDateTimeString(), true},
{"yesterday", carbon.Now().SubDay().ToDateTimeString(), false},
{"not a date", "soon", false},
{"not a string", 12345, false},
}
rule := &rules.FutureDate{}
for _, c := range cases {
s.Equal(c.passes, rule.Passes(context.Background(), nil, c.value), c.name)
}
}
- Each case has a name, a value, and whether the rule should pass it. This table-driven style is common in Go tests, and when a case fails,
s.Equalprints the case's name. - 🔒 is 4 bytes, so 18 of them are exactly 72 bytes and pass. 19 of them are only 19 characters but 76 bytes, and that case fails because the rule counts bytes, not characters.
The tests run with go test ./tests/... --env=.env.testing. Without --env, the guard stops the run before any test, the unit tests included, since they import taskflow/tests too, and its init boots the app. Here and below, the output shows only the test lines:
$ go test ./tests/...
? taskflow/tests [no test files]
panic: the tests drop every table, so they only run on a database whose name ends in _test: go test ./tests/... --env=.env.testing
FAIL taskflow/tests/unit 0.714s
FAIL
With it, both tests pass. -v lists each one:
$ go test ./tests/... --env=.env.testing -v
? taskflow/tests [no test files]
=== RUN TestRulesTestSuite
=== RUN TestRulesTestSuite/TestFutureDate
=== RUN TestRulesTestSuite/TestMaxBytes
--- PASS: TestRulesTestSuite (0.00s)
--- PASS: TestRulesTestSuite/TestFutureDate (0.00s)
--- PASS: TestRulesTestSuite/TestMaxBytes (0.00s)
PASS
ok taskflow/tests/unit 0.398s
Note Go also lets a unit test sit next to its code, like
app/rules/max_bytes_test.go, and a plaingo test ./app/rules/runs it. Either way works, and TaskFlow keeps every test intests/so one command runs them all.
Requests without a token
A feature test sends a request through the routes, the middleware and the handler, as curl did, and checks the answer. The first one checks the simplest rule, that every route behind the auth middleware refuses a request without a valid token. make:test creates its file:
$ go run . artisan make:test feature/AuthTest
SUCCESS Test created successfully
It writes the same suite as RulesTest. Replace it with the checks:
tests/feature/auth_test.go
package feature
import (
"testing"
"github.com/goravel/framework/support/http"
"github.com/stretchr/testify/suite"
"taskflow/app/facades"
"taskflow/app/models"
"taskflow/tests"
)
type AuthTestSuite struct {
suite.Suite
tests.TestCase
}
func TestAuthTestSuite(t *testing.T) {
suite.Run(t, new(AuthTestSuite))
}
func (s *AuthTestSuite) SetupTest() {
s.RefreshDatabase()
}
func (s *AuthTestSuite) TestRoutesNeedAToken() {
for _, path := range []string{"/api/v1/tasks", "/api/v1/tasks/1", "/api/v1/users", "/api/v1/me"} {
response, err := s.Http(s.T()).Get(path)
s.Require().NoError(err)
response.AssertUnauthorized()
}
}
func (s *AuthTestSuite) TestABadTokenIsRefused() {
response, err := s.Http(s.T()).WithToken("not-a-token").Get("/api/v1/tasks")
s.Require().NoError(err)
response.AssertUnauthorized()
}
func (s *AuthTestSuite) TestAWrongPasswordIsRefused() {
var ana models.User
s.Require().NoError(facades.Orm().Factory().Create(&ana, map[string]any{"Email": "ana@example.com"}))
body, err := http.NewBody().SetFields(map[string]any{"email": "ana@example.com", "password": "wrong"}).Build()
s.Require().NoError(err)
response, err := s.Http(s.T()).WithHeader("Content-Type", body.ContentType()).Post("/api/v1/login", body.Reader())
s.Require().NoError(err)
response.AssertUnauthorized()
}
-
SetupTestcallsRefreshDatabase, so every test starts with empty tables intaskflow_test. -
s.Http(s.T())is Goravel's test client. It hands the request straight to the router, with no server and no port, soairdoesn't need to run. - The login request's body is built with Goravel's
http.NewBody():SetFieldstakes the fields as a map, andBuildturns them into JSON, so no JSON is written by hand.body.Reader()is whatPostsends, andbody.ContentType()is the header that goes with it. -
AssertUnauthorizedchecks for a401.
The run now includes the feature tests:
$ go test ./tests/... --env=.env.testing
? taskflow/tests [no test files]
ok taskflow/tests/feature 0.769s
ok taskflow/tests/unit 0.653s
The test client and its assertions:
s.Http(s.T()) starts a request. These are the methods the tests here use, and Goravel's HTTP tests docs list the rest:
-
WithTokensends aBearertoken, andWithHeaderany other header. -
Gettakes the path, andPost,PatchandDeletealso take a body, anio.Reader, ornilfor none. -
Bindfills a struct from the response's JSON. -
AssertStatuschecks for any code, and the common ones have their own name, likeAssertOk,AssertCreated,AssertUnauthorizedandAssertNotFound. -
AssertFluentJsonchecks top-level keys withWhere, so a nested value likeerrors.titleneedsBind.
Goravel's assertions, like testify's assert, mark a test as failed and let it carry on. testify's require, called as s.Require(), stops the test at the first failure instead. The tests use it to check each request's error, since a failed request has no response to check.
Every role rule in one table
The next test checks every role rule from Gates & Policies. Each rule is one row in a table, saying who sends which request and the status it should get, so a broken rule fails under its own name. make:test creates the file:
$ go run . artisan make:test feature/TaskTest
SUCCESS Test created successfully
Replace the new suite with the table and the users it needs:
tests/feature/task_test.go
package feature
import (
"fmt"
"testing"
contractshttp "github.com/goravel/framework/contracts/testing/http"
"github.com/goravel/framework/support/http"
"github.com/stretchr/testify/suite"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/models"
"taskflow/tests"
)
type TaskTestSuite struct {
suite.Suite
tests.TestCase
ana models.User
ben models.User
benTask models.Task
catTask models.Task
}
func TestTaskTestSuite(t *testing.T) {
suite.Run(t, new(TaskTestSuite))
}
func (s *TaskTestSuite) SetupTest() {
s.RefreshDatabase()
s.Require().True(facades.Cache().Flush())
var ana, ben, cat models.User
s.Require().NoError(facades.Orm().Factory().Create(&ana, map[string]any{"Email": "ana@example.com", "Role": enums.RoleManager}))
s.Require().NoError(facades.Orm().Factory().Create(&ben, map[string]any{"Email": "ben@example.com"}))
s.Require().NoError(facades.Orm().Factory().Create(&cat, map[string]any{"Email": "cat@example.com"}))
var benTask, catTask models.Task
s.Require().NoError(facades.Orm().Factory().Create(&benTask, map[string]any{"CreatedBy": ana.ID, "AssignedTo": &ben.ID}))
s.Require().NoError(facades.Orm().Factory().Create(&catTask, map[string]any{"CreatedBy": ana.ID, "AssignedTo": &cat.ID}))
s.ana, s.ben, s.benTask, s.catTask = ana, ben, benTask, catTask
}
func (s *TaskTestSuite) login(email string) string {
body, err := http.NewBody().SetFields(map[string]any{"email": email, "password": "password"}).Build()
s.Require().NoError(err)
response, err := s.Http(s.T()).WithHeader("Content-Type", body.ContentType()).Post("/api/v1/login", body.Reader())
s.Require().NoError(err)
response.AssertOk()
var login struct {
Token string `json:"token"`
}
s.Require().NoError(response.Bind(&login))
s.Require().NotEmpty(login.Token)
return login.Token
}
func (s *TaskTestSuite) TestRoles() {
ana := s.login("ana@example.com")
ben := s.login("ben@example.com")
benTask := fmt.Sprintf("/api/v1/tasks/%d", s.benTask.ID)
catTask := fmt.Sprintf("/api/v1/tasks/%d", s.catTask.ID)
newTask := map[string]any{"title": "Write the tests", "description": "Cover every role rule."}
cases := []struct {
name string
token string
method string
path string
body map[string]any
status int
}{
{"a member reads tasks", ben, "GET", "/api/v1/tasks", nil, 200},
{"a member can't list users", ben, "GET", "/api/v1/users", nil, 403},
{"a manager lists users", ana, "GET", "/api/v1/users", nil, 200},
{"a member can't create a task", ben, "POST", "/api/v1/tasks", newTask, 403},
{"a manager creates a task", ana, "POST", "/api/v1/tasks", newTask, 201},
{"a member changes the status of his task", ben, "PATCH", benTask, map[string]any{"status": "done"}, 200},
{"a member can't rename his task", ben, "PATCH", benTask, map[string]any{"title": "Renamed"}, 403},
{"a member can't change someone else's task", ben, "PATCH", catTask, map[string]any{"status": "done"}, 403},
{"a member reads a task", ben, "GET", benTask, nil, 200},
{"a manager reads a task", ana, "GET", benTask, nil, 200},
{"a member can't see a user", ben, "GET", fmt.Sprintf("/api/v1/users/%d", s.ana.ID), nil, 403},
{"a manager sees a user", ana, "GET", fmt.Sprintf("/api/v1/users/%d", s.ben.ID), nil, 200},
{"a member can't reassign his task", ben, "PATCH", benTask, map[string]any{"assigned_to": s.ana.ID}, 403},
{"a manager changes any task", ana, "PATCH", catTask, map[string]any{"title": "Renamed"}, 200},
{"a member can't delete a task", ben, "DELETE", catTask, nil, 403},
{"a manager deletes a task", ana, "DELETE", catTask, nil, 204},
}
for _, c := range cases {
s.Run(c.name, func() {
body, err := http.NewBody().SetFields(c.body).Build()
s.Require().NoError(err)
request := s.Http(s.T()).WithToken(c.token).WithHeader("Content-Type", body.ContentType())
var response contractshttp.Response
switch c.method {
case "GET":
response, err = request.Get(c.path)
case "POST":
response, err = request.Post(c.path, body.Reader())
case "PATCH":
response, err = request.Patch(c.path, body.Reader())
case "DELETE":
response, err = request.Delete(c.path, nil)
}
s.Require().NoError(err)
response.AssertStatus(c.status)
})
}
}
-
SetupTestcreates Ana, Ben and Cat with the user factory from Factories & Seeding, so each has the passwordpassword. Ben and Cat each get a task. -
loginlogs in throughPOST /api/v1/login, the way a client does, and reads the token withBind. - Each row's body is a map, or
nilwhen the request sends none. Every row builds a body, so the loop stays the same for all of them, but onlyPOSTandPATCHsend it. - The login limit from Auth Middleware & Rate Limits counts attempts in the cache, and
RefreshDatabasedoesn't touch the cache.Cache().Flush()resets the counts, so one test's logins don't use up the next test's. -
s.Runruns each row as its own test, named after the row. - The rows share the same tasks, so Ana's delete comes last.
AuthTest logs in too, so it gets the same Flush, and each suite starts with clean counts whichever runs first:
tests/feature/auth_test.go
// ... 22 lines not shown
func (s *AuthTestSuite) SetupTest() {
s.RefreshDatabase()
+ s.Require().True(facades.Cache().Flush())
}
// ... 24 lines not shown
With -v, each row shows as its own test:
$ go test ./tests/... --env=.env.testing -run TestTaskTestSuite -v
? taskflow/tests [no test files]
--- PASS: TestTaskTestSuite (0.09s)
--- PASS: TestTaskTestSuite/TestRoles (0.09s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_reads_tasks (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_can't_list_users (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_manager_lists_users (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_can't_create_a_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_manager_creates_a_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_changes_the_status_of_his_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_can't_rename_his_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_can't_change_someone_else's_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_reads_a_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_manager_reads_a_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_can't_see_a_user (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_manager_sees_a_user (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_can't_reassign_his_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_manager_changes_any_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_can't_delete_a_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_manager_deletes_a_task (0.00s)
ok taskflow/tests/feature 0.801s
ok taskflow/tests/unit 0.709s [no tests to run]
To see the table catch a mistake, in app/policies/task_policy.go, change Delete to return access.NewAllowResponse(), so members may delete, and run the tests again:
$ go test ./tests/... --env=.env.testing
? taskflow/tests [no test files]
--- FAIL: TestTaskTestSuite (0.05s)
--- FAIL: TestTaskTestSuite/TestRoles (0.05s)
--- FAIL: TestTaskTestSuite/TestRoles/a_member_can't_delete_a_task (0.00s)
Expected response status code [403] but received 204.
--- FAIL: TestTaskTestSuite/TestRoles/a_manager_deletes_a_task (0.00s)
Expected response status code [204] but received 404.
FAIL
FAIL taskflow/tests/feature 0.845s
ok taskflow/tests/unit 0.677s
FAIL
- The member's row names the rule and the status it got.
- Ana's delete then gets a
404, because Ben's went through and the task is gone.
Change Delete back before going on.
Checking the body and the database
A status code doesn't show everything a request did. A 201 could carry the wrong creator, and a 204 could come back without deleting anything, so most of the new tests check the body or the database, and two more cover a missing task and logging out:
tests/feature/task_test.go
// ... 112 lines not shown
response.AssertStatus(c.status)
})
}
}
+
+func (s *TaskTestSuite) TestCreateSetsTheCreator() {
+ body, err := http.NewBody().SetFields(map[string]any{
+ "title": "Write the tests",
+ "description": "Cover every role rule.",
+ "assigned_to": s.ben.ID,
+ }).Build()
+ s.Require().NoError(err)
+ response, err := s.Http(s.T()).WithToken(s.login("ana@example.com")).WithHeader("Content-Type", body.ContentType()).Post("/api/v1/tasks", body.Reader())
+ s.Require().NoError(err)
+
+ response.AssertCreated()
+
+ var task models.Task
+ s.Require().NoError(response.Bind(&task))
+ s.Equal("Write the tests", task.Title)
+ s.Equal(s.ana.ID, task.CreatedBy)
+ s.Equal(s.ben.ID, *task.AssignedTo)
+ s.Equal(enums.StatusPending, task.Status)
+}
+
+func (s *TaskTestSuite) TestAMissingTaskIsNotFound() {
+ response, err := s.Http(s.T()).WithToken(s.login("ana@example.com")).Get("/api/v1/tasks/999")
+ s.Require().NoError(err)
+ response.AssertNotFound()
+}
+
+func (s *TaskTestSuite) TestBadInputIsRefused() {
+ body, err := http.NewBody().SetFields(map[string]any{"description": "No title.", "due_at": "yesterday"}).Build()
+ s.Require().NoError(err)
+ response, err := s.Http(s.T()).WithToken(s.login("ana@example.com")).WithHeader("Content-Type", body.ContentType()).Post("/api/v1/tasks", body.Reader())
+ s.Require().NoError(err)
+
+ response.AssertUnprocessableEntity()
+
+ var refused struct {
+ Errors map[string]any `json:"errors"`
+ }
+ s.Require().NoError(response.Bind(&refused))
+ s.Contains(refused.Errors, "title")
+ s.Contains(refused.Errors, "due_at")
+}
+
+func (s *TaskTestSuite) TestUpdateChangesOnlyTheSentFields() {
+ path := fmt.Sprintf("/api/v1/tasks/%d", s.benTask.ID)
+ body, err := http.NewBody().SetFields(map[string]any{"status": "done"}).Build()
+ s.Require().NoError(err)
+ response, err := s.Http(s.T()).WithToken(s.login("ana@example.com")).WithHeader("Content-Type", body.ContentType()).Patch(path, body.Reader())
+ s.Require().NoError(err)
+
+ response.AssertOk().AssertFluentJson(func(json contractshttp.AssertableJSON) {
+ json.Where("status", "done").
+ Where("title", s.benTask.Title).
+ Where("description", s.benTask.Description)
+ })
+}
+
+func (s *TaskTestSuite) TestDeleteRemovesTheTask() {
+ path := fmt.Sprintf("/api/v1/tasks/%d", s.catTask.ID)
+ response, err := s.Http(s.T()).WithToken(s.login("ana@example.com")).Delete(path, nil)
+ s.Require().NoError(err)
+ response.AssertNoContent()
+
+ count, err := facades.Orm().Query().Model(&models.Task{}).Where("id", s.catTask.ID).Count()
+ s.Require().NoError(err)
+ s.Equal(int64(0), count)
+}
+
+func (s *TaskTestSuite) TestLogoutEndsTheToken() {
+ token := s.login("cat@example.com")
+
+ response, err := s.Http(s.T()).WithToken(token).Post("/api/v1/logout", nil)
+ s.Require().NoError(err)
+ response.AssertNoContent()
+
+ response, err = s.Http(s.T()).WithToken(token).Get("/api/v1/me")
+ s.Require().NoError(err)
+ response.AssertUnauthorized()
+}
-
Bindreads the created task into amodels.Task, and the test checks that its creator is Ana, its assignee is Ben and its status ispending. - A task id that doesn't exist gets a
404. - The bad-input test checks which fields have errors, not the messages, so a reworded message doesn't break it.
- The update test uses
AssertFluentJsonto check that the title and description are still the same after a status-only change. - After the
204, the delete test counts the task's rows in the test database, andCountmust be 0. - In the logout test, Cat logs out and then sends her token again, and that request gets a
401.
The whole suite passes:
$ go test ./tests/... --env=.env.testing
? taskflow/tests [no test files]
ok taskflow/tests/feature 1.071s
ok taskflow/tests/unit 0.713s
Gotcha testify keeps one suite struct for all its tests, so after the first test
s.anastill holds an id, and that's whySetupTestcreates the users into new variables first. Creating Ana straight intos.anawould save her with that old id, but the emptied tables start their ids from 1 again, so the next new user gets the same id:$ go test ./tests/... --env=.env.testing ? taskflow/tests [no test files] --- FAIL: TestTaskTestSuite (0.21s) --- FAIL: TestTaskTestSuite/TestBadInputIsRefused (0.03s) ERROR: duplicate key value violates unique constraint "users_pkey" (SQLSTATE 23505)New variables start with no id, so the database gives each one a fresh id.
Why the tests use the real database:
Goravel can swap facades.Orm() for a mock, but then every query has to be scripted by hand. These tests run the real app against taskflow_test instead. That's slower, but the tests get the same answers a client would.
When a test must not touch a service, such as an email provider, Goravel can replace a facade with a mock. Code that takes its dependencies as arguments instead of calling facades can be given a fake directly.
One go test ./tests/... --env=.env.testing now checks every role rule from Gates & Policies, the token checks and the main task endpoints, in about a second.
Previous: 12. Gates & Policies
Next: 14. File Storage
Series: Goravel
Top comments (0)