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)
# Run ALL tests
./scripts/run_tests.sh -v ./tests/...
# Run specific CRUD test suite
./scripts/run_tests.sh -v ./tests/feature/crud -run TestEntityCRUDTestSuite
# Run specific test within a suite
./scripts/run_tests.sh -v ./tests/feature/crud -run TestEntityCRUDTestSuite/TestCreateEntity
# Run integration tests
./scripts/run_tests.sh -v ./tests/integration/...
# Run unit tests (no DB required but script still works)
./scripts/run_tests.sh -v ./tests/unit/...
# Run with verbose + count
./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"
)
// ============================================================================
// Suite Definition
// ============================================================================
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{})
}
// ============================================================================
// Suite Lifecycle (runs ONCE per suite)
// ============================================================================
func (s *EntityCRUDTestSuite) SetupSuite() {
// Start test HTTP server
s.server = httptest.NewServer(facades.Route())
// Create HTTP client with cookie jar for session management
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 // Don't follow redirects
},
}
}
func (s *EntityCRUDTestSuite) TearDownSuite() {
if s.server != nil {
s.server.Close()
}
}
// ============================================================================
// Test Lifecycle (runs BEFORE/AFTER each test)
// ============================================================================
func (s *EntityCRUDTestSuite) SetupTest() {
// Clean database for isolation
s.RefreshDatabase()
// Create test user with permissions
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_%'")
}
}
// ============================================================================
// Helper: Test User Setup
// ============================================================================
func (s *EntityCRUDTestSuite) setupTestUser() {
// Create admin role
adminRole := &models.Role{
Name: "Entity Admin",
Slug: "entity_admin",
Level: 100,
}
s.Nil(facades.Orm().Query().Create(adminRole))
// Create CRUD permissions
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"))
}
// Create test user with JWT auth
user, err := helpers.SetupJWTUser("entitytest@example.com", "password", adminRole)
s.Nil(err)
s.testUser = user
// Login to get auth cookie
s.authCookie = s.loginUser("entitytest@example.com", "password")
s.NotNil(s.authCookie, "Login should return auth cookie")
}
// ============================================================================
// Helper: Login
// ============================================================================
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()
// Extract token cookie
for _, cookie := range resp.Cookies() {
if cookie.Name == "token" {
return cookie
}
}
return nil
}
// ============================================================================
// Helper: Make Authenticated Request
// ============================================================================
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")
// Add auth cookie
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
}
// ============================================================================
// Helper: Create Test Entity
// ============================================================================
func (s *EntityCRUDTestSuite) createTestEntity(name string) map[string]interface{} {
data := map[string]interface{}{
"name": name,
"description": "Test description for " + name,
"status": "ACTIVE",
// Add other required fields...
}
resp, result := s.makeRequest("POST", "/api/entity-names", data)
s.Equal(http.StatusCreated, resp.StatusCode, "Create should return 201")
return result
}
// ============================================================================
// CRUD Tests
// ============================================================================
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() {
// Create an entity first
created := s.createTestEntity("Get Test")
// Extract ID from response
data := created["data"].(map[string]interface{})
id := int(data["id"].(float64))
// GET by ID
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() {
// Create first
created := s.createTestEntity("Update Test")
data := created["data"].(map[string]interface{})
id := int(data["id"].(float64))
// Update
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() {
// Create first
created := s.createTestEntity("Delete Test")
data := created["data"].(map[string]interface{})
id := int(data["id"].(float64))
// Delete
resp, _ := s.makeRequest("DELETE", fmt.Sprintf("/api/entity-names/%d", id), nil)
s.Equal(http.StatusNoContent, resp.StatusCode)
// Verify deleted (should be 404)
resp2, _ := s.makeRequest("GET", fmt.Sprintf("/api/entity-names/%d", id), nil)
s.Equal(http.StatusNotFound, resp2.StatusCode)
}
func (s *EntityCRUDTestSuite) TestListEntities() {
// Create multiple entities
for i := 1; i <= 5; i++ {
s.createTestEntity(fmt.Sprintf("List Test %d", i))
}
// List
resp, result := s.makeRequest("GET", "/api/entity-names?page=1&pageSize=10", nil)
s.Equal(http.StatusOK, resp.StatusCode)
// Check pagination structure
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")
// Search
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")
// Sort ascending by name
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)
// Verify order
first := items[0].(map[string]interface{})
s.Equal("Alpha", first["name"])
}
}
}
func (s *EntityCRUDTestSuite) TestPagination() {
// Create 15 entities
for i := 1; i <= 15; i++ {
s.createTestEntity(fmt.Sprintf("Page Test %02d", i))
}
// Get page 1 with size 5
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))
}
// Check pagination metadata
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() {
// Send empty data (missing required fields)
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:
// Admin: sees ALL records (by_all)
helpers.AssignPermissionToRole(adminRole, readPerm, "by_all")
// Editor: sees records from users with same role (by_my_role)
helpers.AssignPermissionToRole(editorRole, readPerm, "by_my_role")
// Member: sees only their OWN records (by_me)
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))
// Access first item
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
}
// Junction tables first
orm.Query().Exec("DELETE FROM user_roles")
orm.Query().Exec("DELETE FROM role_permissions")
// Entity data
orm.Query().Exec("DELETE FROM entities")
// Auth data
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() {
// Seed data
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.gostarts container ininit()functiontest_bootstrap.goboots 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{}notnilin 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
tokencookie — must be attached to every request - 2FA disabled:
AUTH_REQUIRE_2FA=falsemust be set (script handles this) - Sequential packages:
-p=1prevents migration race conditions - Float assertion: JSON numbers decode as
float64, usefloat64(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