| name | goravel-test-suite |
| description | Write and run comprehensive Go test suites for Goravel entities using testcontainers (PostgreSQL). Covers CRUD tests, permission tests, scoped access tests, and integration tests with the full test infrastructure. |
| argument-hint | [EntityName] or [test-type] |
| allowed-tools | Bash, Read, Write, Edit, Grep, Glob |
Goravel Test Suite (Testcontainers)
Write/run tests for $ARGUMENTS.
Running Tests
Using the Shell Script (recommended)
./scripts/run_tests.sh -v ./tests/...
./scripts/run_tests.sh -v ./tests/feature/crud -run TestEntityCRUDTestSuite
./scripts/run_tests.sh -v ./tests/feature/crud -run TestEntityCRUDTestSuite/TestCreateEntity
./scripts/run_tests.sh -v ./tests/integration/...
./scripts/run_tests.sh -v ./tests/unit/...
./scripts/run_tests.sh -v -count=1 ./tests/feature/crud -run TestEntityCRUDTestSuite
What run_tests.sh does:
- Starts a PostgreSQL 16 Alpine Docker container with random port
- Creates isolated temp storage directory (
/tmp/books-test-storage-{PID})
- Sets environment variables (
APP_ENV=testing, DB_*, JWT_SECRET, etc.)
- Runs
go test -p=1 (sequential packages to avoid migration conflicts)
- Cleans up container + temp dir on exit (even on CTRL+C)
Key Flags
| Flag | Purpose |
|---|
-v | Verbose output |
-count=1 | Disable test caching |
-run TestSuiteName | Run specific suite |
-run TestSuiteName/TestMethod | Run specific test method |
-p=1 | Sequential packages (already set by script) |
-timeout 5m | Increase timeout for slow tests |
Test Directory Structure
tests/
├── test_case.go # Base TestCase (embeds Goravel's TestCase)
├── bootstrap/ # Testcontainer init (env_setup.go, test_bootstrap.go)
├── helpers/ # Shared utilities
│ ├── auth_test_helper.go # Mock auth contexts
│ ├── database_cleaner.go # TRUNCATE CASCADE cleanup
│ ├── jwt_workaround.go # SetupJWTUser helper
│ └── http_scoped_permissions_helper.go # AssignPermissionToRole
├── feature/crud/ # CRUD operation tests
│ ├── main_test.go # Package bootstrap
│ ├── <entity>_crud_test.go # Entity CRUD test suite
│ └── ...
├── integration/ # Service/API integration tests
│ ├── api/ # API endpoint tests
│ └── services/ # Service layer tests
└── unit/ # Pure unit tests (no DB)
Writing a CRUD Test Suite
Step 1: Create Test File
Create tests/feature/crud/<entity>_crud_test.go:
package crud
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"testing"
"time"
"github.com/goravel/framework/facades"
"github.com/stretchr/testify/suite"
"books-database/app/models"
"books-database/tests"
"books-database/tests/helpers"
)
type EntityCRUDTestSuite struct {
suite.Suite
tests.TestCase
server *httptest.Server
client *http.Client
authCookie *http.Cookie
testUser *models.User
}
func TestEntityCRUDTestSuite(t *testing.T) {
suite.Run(t, &EntityCRUDTestSuite{})
}
func (s *EntityCRUDTestSuite) SetupSuite() {
s.server = httptest.NewServer(facades.Route())
jar, _ := cookiejar.New(nil)
s.client = &http.Client{
Jar: jar,
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
func (s *EntityCRUDTestSuite) TearDownSuite() {
if s.server != nil {
s.server.Close()
}
}
func (s *EntityCRUDTestSuite) SetupTest() {
s.RefreshDatabase()
s.setupTestUser()
}
func (s *EntityCRUDTestSuite) TearDownTest() {
if orm := facades.Orm(); orm != nil {
orm.Query().Exec("DELETE FROM entities")
orm.Query().Exec("DELETE FROM users WHERE email = 'entitytest@example.com'")
orm.Query().Exec("DELETE FROM user_roles")
orm.Query().Exec("DELETE FROM role_permissions")
orm.Query().Exec("DELETE FROM roles WHERE slug = 'entity_admin'")
orm.Query().Exec("DELETE FROM permissions WHERE slug LIKE 'entities_%'")
}
}
func (s *EntityCRUDTestSuite) setupTestUser() {
adminRole := &models.Role{
Name: "Entity Admin",
Slug: "entity_admin",
Level: 100,
}
s.Nil(facades.Orm().Query().Create(adminRole))
permissions := []string{"create", "read", "update", "delete"}
for _, perm := range permissions {
permission := &models.Permission{
Name: fmt.Sprintf("entities_%s", perm),
Slug: fmt.Sprintf("entities_%s", perm),
Scope: "by_all",
}
s.Nil(facades.Orm().Query().Create(permission))
s.Nil(helpers.AssignPermissionToRole(adminRole, permission, "by_all"))
}
user, err := helpers.SetupJWTUser("entitytest@example.com", "password", adminRole)
s.Nil(err)
s.testUser = user
s.authCookie = s.loginUser("entitytest@example.com", "password")
s.NotNil(s.authCookie, "Login should return auth cookie")
}
func (s *EntityCRUDTestSuite) loginUser(email, password string) *http.Cookie {
loginData := map[string]string{
"email": email,
"password": password,
}
jsonData, _ := json.Marshal(loginData)
resp, err := s.client.Post(
s.server.URL+"/api/auth/login",
"application/json",
bytes.NewBuffer(jsonData),
)
s.Nil(err)
defer resp.Body.Close()
for _, cookie := range resp.Cookies() {
if cookie.Name == "token" {
return cookie
}
}
return nil
}
func (s *EntityCRUDTestSuite) makeRequest(method, path string, body interface{}) (*http.Response, map[string]interface{}) {
var bodyReader io.Reader
if body != nil {
jsonBody, _ := json.Marshal(body)
bodyReader = bytes.NewBuffer(jsonBody)
}
req, err := http.NewRequest(method, s.server.URL+path, bodyReader)
s.Nil(err)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
if s.authCookie != nil {
req.AddCookie(s.authCookie)
}
resp, err := s.client.Do(req)
s.Nil(err)
respBody, err := io.ReadAll(resp.Body)
s.Nil(err)
resp.Body.Close()
var result map[string]interface{}
if len(respBody) > 0 {
json.Unmarshal(respBody, &result)
}
return resp, result
}
func (s *EntityCRUDTestSuite) createTestEntity(name string) map[string]interface{} {
data := map[string]interface{}{
"name": name,
"description": "Test description for " + name,
"status": "ACTIVE",
}
resp, result := s.makeRequest("POST", "/api/entity-names", data)
s.Equal(http.StatusCreated, resp.StatusCode, "Create should return 201")
return result
}
func (s *EntityCRUDTestSuite) TestCreateEntity() {
data := map[string]interface{}{
"name": "Test Entity",
"description": "A test entity",
"status": "ACTIVE",
}
resp, result := s.makeRequest("POST", "/api/entity-names", data)
s.Equal(http.StatusCreated, resp.StatusCode)
s.NotNil(result["data"])
}
func (s *EntityCRUDTestSuite) TestGetEntity() {
created := s.createTestEntity("Get Test")
data := created["data"].(map[string]interface{})
id := int(data["id"].(float64))
resp, result := s.makeRequest("GET", fmt.Sprintf("/api/entity-names/%d", id), nil)
s.Equal(http.StatusOK, resp.StatusCode)
s.NotNil(result["data"])
}
func (s *EntityCRUDTestSuite) TestUpdateEntity() {
created := s.createTestEntity("Update Test")
data := created["data"].(map[string]interface{})
id := int(data["id"].(float64))
updateData := map[string]interface{}{
"name": "Updated Name",
}
resp, _ := s.makeRequest("PUT", fmt.Sprintf("/api/entity-names/%d", id), updateData)
s.Equal(http.StatusOK, resp.StatusCode)
}
func (s *EntityCRUDTestSuite) TestDeleteEntity() {
created := s.createTestEntity("Delete Test")
data := created["data"].(map[string]interface{})
id := int(data["id"].(float64))
resp, _ := s.makeRequest("DELETE", fmt.Sprintf("/api/entity-names/%d", id), nil)
s.Equal(http.StatusNoContent, resp.StatusCode)
resp2, _ := s.makeRequest("GET", fmt.Sprintf("/api/entity-names/%d", id), nil)
s.Equal(http.StatusNotFound, resp2.StatusCode)
}
func (s *EntityCRUDTestSuite) TestListEntities() {
for i := 1; i <= 5; i++ {
s.createTestEntity(fmt.Sprintf("List Test %d", i))
}
resp, result := s.makeRequest("GET", "/api/entity-names?page=1&pageSize=10", nil)
s.Equal(http.StatusOK, resp.StatusCode)
if dataWrapper, ok := result["data"].(map[string]interface{}); ok {
if items, ok := dataWrapper["data"].([]interface{}); ok {
s.GreaterOrEqual(len(items), 5)
}
}
}
func (s *EntityCRUDTestSuite) TestSearchEntities() {
s.createTestEntity("Searchable Alpha")
s.createTestEntity("Searchable Beta")
s.createTestEntity("Other Entity")
resp, result := s.makeRequest("GET", "/api/entity-names/search?q=Searchable", nil)
s.Equal(http.StatusOK, resp.StatusCode)
if dataWrapper, ok := result["data"].(map[string]interface{}); ok {
if items, ok := dataWrapper["data"].([]interface{}); ok {
s.Equal(2, len(items))
}
}
}
func (s *EntityCRUDTestSuite) TestSortEntities() {
s.createTestEntity("Charlie")
s.createTestEntity("Alpha")
s.createTestEntity("Beta")
resp, result := s.makeRequest("GET", "/api/entity-names?sort=name&direction=ASC", nil)
s.Equal(http.StatusOK, resp.StatusCode)
if dataWrapper, ok := result["data"].(map[string]interface{}); ok {
if items, ok := dataWrapper["data"].([]interface{}); ok {
s.GreaterOrEqual(len(items), 3)
first := items[0].(map[string]interface{})
s.Equal("Alpha", first["name"])
}
}
}
func (s *EntityCRUDTestSuite) TestPagination() {
for i := 1; i <= 15; i++ {
s.createTestEntity(fmt.Sprintf("Page Test %02d", i))
}
resp, result := s.makeRequest("GET", "/api/entity-names?page=1&pageSize=5", nil)
s.Equal(http.StatusOK, resp.StatusCode)
if dataWrapper, ok := result["data"].(map[string]interface{}); ok {
if items, ok := dataWrapper["data"].([]interface{}); ok {
s.Equal(5, len(items))
}
if pagination, ok := dataWrapper["pagination"].(map[string]interface{}); ok {
s.Equal(float64(15), pagination["total"])
s.Equal(float64(3), pagination["last_page"])
}
}
}
func (s *EntityCRUDTestSuite) TestCreateValidationError() {
data := map[string]interface{}{}
resp, _ := s.makeRequest("POST", "/api/entity-names", data)
s.Equal(http.StatusUnprocessableEntity, resp.StatusCode)
}
func (s *EntityCRUDTestSuite) TestGetNonExistentEntity() {
resp, _ := s.makeRequest("GET", "/api/entity-names/99999", nil)
s.Equal(http.StatusNotFound, resp.StatusCode)
}
Step 2: Verify main_test.go Exists
Each test package needs a main_test.go. Check tests/feature/crud/main_test.go exists:
package crud
import (
"os"
"testing"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/foundation"
_ "books-database/config"
"books-database/tests/helpers"
)
func TestMain(m *testing.M) {
os.Setenv("AUTH_REQUIRE_2FA", "false")
app := foundation.NewApplication()
app.Boot()
if err := facades.Artisan().Call("migrate"); err != nil {
os.Stderr.WriteString("Warning: Migration failed: " + err.Error() + "\n")
}
if err := helpers.CleanTestDatabase(); err != nil {
os.Stderr.WriteString("Warning: Database cleanup failed: " + err.Error() + "\n")
}
code := m.Run()
helpers.CleanTestDatabase()
os.Exit(code)
}
Important: If adding tests to a NEW package (not tests/feature/crud/), you must create a main_test.go for that package.
Step 3: Run Tests
./scripts/run_tests.sh -v ./tests/feature/crud -run TestEntityCRUDTestSuite
Test Helpers Reference
helpers.SetupJWTUser(email, password, role) (*models.User, error)
Creates or updates a test user with hashed password and assigned role. Returns fully populated user.
user, err := helpers.SetupJWTUser("test@example.com", "password", adminRole)
helpers.AssignPermissionToRole(role, permission, scope) error
Assigns a permission to a role with a specific scope.
helpers.AssignPermissionToRole(adminRole, readPerm, "by_all")
Scopes: "by_all", "by_my_role", "by_me"
helpers.CleanTestDatabase() error
TRUNCATE CASCADE all tables except migrations. PostgreSQL-specific.
helpers.CleanupTestData()
Pattern-based cleanup: deletes test data matching patterns like Test %, %@example.com, test_%.
Permission Setup Patterns
Full CRUD Permissions
func (s *Suite) setupTestUser() {
role := &models.Role{Name: "Admin", Slug: "admin", Level: 100}
facades.Orm().Query().Create(role)
for _, action := range []string{"create", "read", "update", "delete"} {
perm := &models.Permission{
Name: fmt.Sprintf("entities_%s", action),
Slug: fmt.Sprintf("entities_%s", action),
Scope: "by_all",
}
facades.Orm().Query().Create(perm)
helpers.AssignPermissionToRole(role, perm, "by_all")
}
user, _ := helpers.SetupJWTUser("test@example.com", "password", role)
s.testUser = user
s.authCookie = s.loginUser("test@example.com", "password")
}
Scoped Permission Testing
Test that different scopes restrict data visibility:
helpers.AssignPermissionToRole(adminRole, readPerm, "by_all")
helpers.AssignPermissionToRole(editorRole, readPerm, "by_my_role")
helpers.AssignPermissionToRole(memberRole, readPerm, "by_me")
Response Assertion Patterns
Nested Response Structure
The API returns {data: {data: [...], pagination: {...}}} for lists:
if dataWrapper, ok := result["data"].(map[string]interface{}); ok {
if items, ok := dataWrapper["data"].([]interface{}); ok {
s.Equal(5, len(items))
first := items[0].(map[string]interface{})
s.Equal("Expected Name", first["name"])
}
if pagination, ok := dataWrapper["pagination"].(map[string]interface{}); ok {
s.Equal(float64(15), pagination["total"])
s.Equal(float64(1), pagination["current_page"])
}
}
Single Resource Response
if data, ok := result["data"].(map[string]interface{}); ok {
s.Equal("Expected Name", data["name"])
s.Equal(float64(1), data["id"])
}
Error Response
s.Equal(http.StatusUnprocessableEntity, resp.StatusCode)
if errors, ok := result["errors"].(map[string]interface{}); ok {
s.NotNil(errors["name"], "Should have name validation error")
}
Database Cleanup in TearDownTest
Always clean up test data in reverse dependency order:
func (s *Suite) TearDownTest() {
orm := facades.Orm()
if orm == nil {
return
}
orm.Query().Exec("DELETE FROM user_roles")
orm.Query().Exec("DELETE FROM role_permissions")
orm.Query().Exec("DELETE FROM entities")
orm.Query().Exec("DELETE FROM users WHERE email = 'entitytest@example.com'")
orm.Query().Exec("DELETE FROM roles WHERE slug = 'entity_admin'")
orm.Query().Exec("DELETE FROM permissions WHERE slug LIKE 'entities_%'")
}
Writing Integration Tests (Service Layer)
For testing services directly without HTTP:
package services
import (
"testing"
"github.com/goravel/framework/facades"
"github.com/stretchr/testify/suite"
"books-database/app/models"
"books-database/app/services"
"books-database/app/contracts"
"books-database/tests"
)
type EntityServiceTestSuite struct {
suite.Suite
tests.TestCase
}
func TestEntityServiceTestSuite(t *testing.T) {
suite.Run(t, new(EntityServiceTestSuite))
}
func (s *EntityServiceTestSuite) SetupTest() {
s.RefreshDatabase()
}
func (s *EntityServiceTestSuite) TestCreate() {
svc := services.NewEntityService()
data := map[string]interface{}{
"name": "Test",
"status": "ACTIVE",
}
result, err := svc.Create(data)
s.NoError(err)
s.NotNil(result)
}
func (s *EntityServiceTestSuite) TestGetList() {
for i := 0; i < 5; i++ {
facades.Orm().Query().Create(&models.Entity{
Name: fmt.Sprintf("Entity %d", i),
Status: "ACTIVE",
})
}
svc := services.NewEntityService()
result, err := svc.GetList(contracts.ListRequest{
Page: 1,
PageSize: 10,
})
s.NoError(err)
s.NotNil(result)
s.Equal(5, len(result.Data))
}
func (s *EntityServiceTestSuite) TestSearch() {
facades.Orm().Query().Create(&models.Entity{Name: "Findable", Status: "ACTIVE"})
facades.Orm().Query().Create(&models.Entity{Name: "Other", Status: "ACTIVE"})
svc := services.NewEntityService()
result, err := svc.Search("Find", contracts.ListRequest{Page: 1, PageSize: 10})
s.NoError(err)
s.Equal(1, len(result.Data))
}
Writing Unit Tests (No Database)
For testing service logic without a database:
package unit
import (
"testing"
"github.com/stretchr/testify/assert"
"books-database/app/services"
)
func TestEntityServiceFieldMapping(t *testing.T) {
svc := services.NewEntityService()
tests := []struct {
name string
input string
expected string
ok bool
}{
{"Map createdAt", "createdAt", "created_at", true},
{"Map updatedAt", "updatedAt", "updated_at", true},
{"Map unknown", "fooBar", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
field, ok := svc.MapSortField(tt.input)
assert.Equal(t, tt.ok, ok)
if tt.ok {
assert.Equal(t, tt.expected, field)
}
})
}
}
Testcontainer Architecture
Shell Script (scripts/run_tests.sh) — Recommended
- Starts PostgreSQL container via
docker run
- Sets env vars BEFORE Go compiles test binary
- Cleanup trap on EXIT/INT/TERM
- Sequential packages with
-p=1
Go Module (tests/bootstrap/) — Alternative
env_setup.go starts container in init() function
test_bootstrap.go boots Goravel app and runs migrations
- Used by integration tests that import
tests/bootstrap
When to Use Which
| Approach | Use When |
|---|
run_tests.sh | Running tests locally or in CI. Works with any test package. |
| Go bootstrap | Tests that need programmatic control over the container lifecycle. |
Common Gotchas
- JSON array fields: Initialize as
[]string{} not nil in test data
- Permission slug format:
entities_read (underscore between service and action)
- Route ordering: Search/filter routes MUST come before
{id} route
- Auth cookie: Login returns a
token cookie — must be attached to every request
- 2FA disabled:
AUTH_REQUIRE_2FA=false must be set (script handles this)
- Sequential packages:
-p=1 prevents migration race conditions
- Float assertion: JSON numbers decode as
float64, use float64(expected) in assertions
- Nested response: List endpoints return
{data: {data: [...], pagination: {...}}}
Reference
- Test script:
scripts/run_tests.sh
- Test case base:
tests/test_case.go
- Bootstrap:
tests/bootstrap/env_setup.go, tests/bootstrap/test_bootstrap.go
- Helpers:
tests/helpers/jwt_workaround.go, tests/helpers/database_cleaner.go
- CRUD test example:
tests/feature/crud/lender_crud_test.go
- Advanced test example:
tests/feature/crud/book_advanced_features_test.go
- Scoped permissions test:
tests/integration/api/api_scoped_permissions_test.go