# Feature Testing

> Tests for logins, roles and tasks.

Goravel series, part 13 of 20. By Krishan Kumar. Published 2026-10-25.
Source: https://devchalk.com/goravel/13-feature-testing
Code: https://github.com/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:

```bash
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`:

```bash t1:testdb=/(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`](https://www.goravel.dev/testing/getting-started#custom-environment-file) instead of `.env` when `go test` gets `--env=.env.testing`. It starts as a copy of `.env.example`:

```bash
$ cp .env.example .env.testing
```

These lines change:

```diff lang="ini" title=".env.testing" collapse={8-15} t1:testdb=/(taskflow_test)/ t2:rounds=/(HASH_ROUNDS)=/ t3:store=/(CACHE_STORE)=/
 APP_NAME=Goravel
-APP_ENV=local
-APP_KEY=
+APP_ENV=testing
+APP_KEY=2Nr9bpCIal8Dj0qYE7IviHcYcwzmVqoh
 APP_DEBUG=true
 
 LOG_CHANNEL=stack
 LOG_LEVEL=debug
 
 SESSION_DRIVER=file
 SESSION_LIFETIME=120
 
 APP_URL=http://localhost
 APP_HOST=127.0.0.1
 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:

```diff lang="go" title="config/cache.go" collapse={1-9} collapse={16-42} t3:store=/"(CACHE_STORE)"/
 package config
 
 import (
 	"github.com/goravel/framework/contracts/cache"
 	redisfacades "github.com/goravel/redis/facades"
 	"taskflow/app/facades"
 )
 
 func init() {
 	config := facades.Config()
 	config.Add("cache", map[string]any{
-		"default": "redis",
+		"default": config.Env("CACHE_STORE", "redis"),
 
 		// Cache Stores
 		//
 		// Here you may define all the cache "stores" for your application as
 		// well as their drivers. You may even define multiple stores for the
 		// same cache driver to group types of items stored in your caches.
 		// Available Drivers: "memory", "custom"
 		"stores": map[string]any{
 			"memory": map[string]any{
 				"driver": "memory",
 			},
 			"redis": map[string]any{
 				"driver":     "custom",
 				"connection": "default",
 				"via": func() (cache.Driver, error) {
 					return redisfacades.Cache("redis") // The `redis` value is the key of `stores`
 				},
 			},
 		},
 
 		// Cache Key Prefix
 		//
 		// When utilizing a RAM based store such as APC or Memcached, there might
 		// be other applications utilizing the same cache. So, we'll specify a
 		// value to get prefixed to all our keys, so we can avoid collisions.
 		// Must: a-zA-Z0-9_-
 		"prefix": config.GetString("APP_NAME", "goravel") + "_cache",
 	})
 }
```

```diff lang="go" title="config/hashing.go" collapse={1-23} collapse={30-41} t2:rounds=/"(HASH_ROUNDS)"/
 package config
 
 import (
 	"taskflow/app/facades"
 )
 
 func init() {
 	config := facades.Config()
 	config.Add("hashing", map[string]any{
 		// Hashing Driver
 		//
 		// This option controls the default diver that gets used
 		// by the framework hash facade.
 		// Default driver is "bcrypt".
 		//
 		// Supported Drivers: "bcrypt", "argon2id"
 		"driver": "bcrypt",
 
 		// Bcrypt Hashing Options
 		// rounds: The cost factor that should be used to compute the bcrypt hash.
 		// The cost factor controls how much time is needed to compute a single bcrypt hash.
 		// The higher the cost factor, the more hashing rounds are done. Increasing the cost
 		// factor by 1 doubles the necessary time. After a certain point, the returns on
 		// 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),
 		},
 
 		// Argon2id Hashing Options
 		// memory: A memory cost, which defines the memory usage, given in kibibytes.
 		// time: A time cost, which defines the amount of computation
 		// realized and therefore the execution time, given in number of iterations.
 		// threads: A parallelism degree, which defines the number of parallel threads.
 		"argon2id": map[string]any{
 			"memory":  65536,
 			"time":    4,
 			"threads": 1,
 		},
 	})
 }
```

- `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:

```diff lang="ini" title=".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](https://devchalk.com/goravel/01-installation-project-architecture#create-the-project) didn't pick Testing, so install it now with [`package:install`](https://www.goravel.dev/getting-started/installation):

```bash
$ 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](https://github.com/stretchr/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:

```diff lang="go" title="tests/test_case.go" t1:boot=/bootstrap[.](Boot)[(][)]/ t2:guard=/(they only run on a database whose name ends in _test)/
 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`](https://go.dev/ref/spec#Package_initialization) 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`](https://www.goravel.dev/testing/getting-started#refresh-database-or-cache) 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](https://devchalk.com/goravel/09-register-login-jwt#registering) and `FutureDate` from [Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests#a-custom-rule). [`make:test`](https://www.goravel.dev/testing/getting-started#creating-tests) creates their test file:

```bash
$ go run . artisan make:test unit/RulesTest
 SUCCESS  Test created successfully
```

It writes a [testify suite](https://pkg.go.dev/github.com/stretchr/testify/suite), a struct whose methods are the tests:

```go title="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:

```go title="tests/unit/rules_test.go" t1:emoji=/("19 emoji are 76 bytes")/ t3:casename=/c[.](name)[)]/
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](https://go.dev/wiki/TableDrivenTests) 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:

```bash t2:guard=/(they only run on a database whose name ends in _test)/
$ 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`](https://pkg.go.dev/cmd/go#hdr-Testing_flags) lists each one:

```bash
$ 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:

```bash
$ go run . artisan make:test feature/AuthTest
 SUCCESS  Test created successfully
```

It writes the same suite as `RulesTest`. Replace it with the checks:

```go title="tests/feature/auth_test.go" t3:http=/s[.](Http)[(]s[.]T[(][)][)]/ t2:unauth=/[.](AssertUnauthorized)[(]/
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](https://www.goravel.dev/testing/http-tests#make-requests). 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()`](https://www.goravel.dev/testing/http-tests#building-body): `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:

```bash
$ 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](https://www.goravel.dev/testing/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`](https://www.goravel.dev/testing/http-tests#fluent-json-testing) checks top-level keys with `Where`, so a nested value like `errors.title` needs `Bind`.

Goravel's assertions, like testify's [`assert`](https://pkg.go.dev/github.com/stretchr/testify/assert), mark a test as failed and let it carry on. testify's [`require`](https://pkg.go.dev/github.com/stretchr/testify/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](https://devchalk.com/goravel/12-gates-policies#the-rules). 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:

```bash
$ go run . artisan make:test feature/TaskTest
 SUCCESS  Test created successfully
```

Replace the new suite with the table and the users it needs:

```go title="tests/feature/task_test.go" t3:row=/"(a member can't delete a task)"/ t2:flush=/(Cache[(][)][.]Flush)[(]/ t1:login=/[)] (login)[(]email/
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)
		})
	}
}
```

- `SetupTest` creates Ana, Ben and Cat with the user factory from [Factories & Seeding](https://devchalk.com/goravel/07-factories-seeding#the-user-factory), so each has the password `password`. Ben and Cat each get a task.
- `login` logs in through `POST /api/v1/login`, the way a client does, and reads the token with `Bind`.
- Each row's body is a map, or `nil` when the request sends none. Every row builds a body, so the loop stays the same for all of them, but only `POST` and `PATCH` send it.
- The login limit from [Auth Middleware & Rate Limits](https://devchalk.com/goravel/10-auth-middleware#limiting-login-attempts) counts attempts in the cache, and `RefreshDatabase` doesn't touch the cache. `Cache().Flush()` resets the counts, so one test's logins don't use up the next test's.
- [`s.Run`](https://pkg.go.dev/github.com/stretchr/testify/suite#Suite.Run) runs 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:

```diff lang="go" title="tests/feature/auth_test.go" collapse={1-22} collapse={28-51} t2:flush=/(Cache[(][)][.]Flush)[(]/
 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()
+	s.Require().True(facades.Cache().Flush())
 }
 
 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()
 }
```

With `-v`, each row shows as its own test:

```bash
$ 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:

```bash t3:row=/(a_member_can't_delete_a_task)/
$ 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:

```diff lang="go" title="tests/feature/task_test.go" collapse={1-112} t1:bind=/response[.](Bind)[(]&task[)]/ t3:count=/[.](Count)[(][)]/
 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)
 		})
 	}
 }
+
+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()
+}
```

- `Bind` reads the created task into a `models.Task`, and the test checks that its creator is Ana, its assignee is Ben and its status is `pending`.
- 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 [`AssertFluentJson`](https://www.goravel.dev/testing/http-tests#fluent-json-testing) to 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, and `Count` must 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:

```bash
$ 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.ana` still holds an id, and that's why `SetupTest` creates the users into new variables first. Creating Ana straight into `s.ana` would 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:
>
> ```bash
> $ 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](https://www.goravel.dev/testing/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](https://devchalk.com/goravel/12-gates-policies), the token checks and the main task endpoints, in about a second.

---

Previous: [12. Gates & Policies](https://devchalk.com/goravel/12-gates-policies.md)
Next: [14. File Storage](https://devchalk.com/goravel/14-file-storage-attachments.md)
Series: [Goravel](https://devchalk.com/goravel.md)
