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=memory keeps 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=4 makes 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 the TestCase struct every test embeds.
  • tests/feature/example_test.go, a placeholder test to delete.
  • &testing.ServiceProvider{} in bootstrap/providers.go. It sets up what RefreshDatabase and Http need.
  • app/facades/testing.go, the Testing facade, and testify in go.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 init once in each test package that imports taskflow/tests, before its tests. bootstrap.Boot() is the call main.go makes, 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 plain go test without --env, and an .env.testing that still says taskflow.
  • Embedding Goravel's testing.TestCase gives every test Http for requests and RefreshDatabase for 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 } 
  • RulesTestSuite embeds suite.Suite, which gives it testify's checks like s.Equal, and tests.TestCase from the last section.
  • TestRulesTestSuite is the only function go test itself runs. Its suite.Run then calls every method on the struct whose name starts with Test.
  • SetupTest and TearDownTest run before and after each of those methods, and TestIndex is 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.Equal prints 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 plain go test ./app/rules/ runs it. Either way works, and TaskFlow keeps every test in tests/ 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() } 
  • SetupTest calls RefreshDatabase, so every test starts with empty tables in taskflow_test.
  • s.Http(s.T()) is Goravel's test client. It hands the request straight to the router, with no server and no port, so air doesn't need to run.
  • The login request's body is built with Goravel's http.NewBody(): SetFields takes the fields as a map, and Build turns them into JSON, so no JSON is written by hand. body.Reader() is what Post sends, and body.ContentType() is the header that goes with it.
  • AssertUnauthorized checks for a 401.

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:

  • WithToken sends a Bearer token, and WithHeader any other header.
  • Get takes the path, and Post, Patch and Delete also take a body, an io.Reader, or nil for none.
  • Bind fills a struct from the response's JSON.
  • AssertStatus checks for any code, and the common ones have their own name, like AssertOk, AssertCreated, AssertUnauthorized and AssertNotFound.
  • AssertFluentJson checks top-level keys with Where, so a nested value like errors.title needs Bind.

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 ...
Source:https://dev.to/krishankumar01/goravel-feature-testing-for-a-rest-api-1h2a