Stage 7 · Master
Phase 2 — Organization Service
Unit Testing
Test every business rule this course has written into OrganizationService against a hand-written fake — no database, no Docker, no network socket anywhere in this lesson.
A Fake, Not a Mock Framework
internal/service/repository.go's Repository interface exists precisely so a test can satisfy it without a real database. This lesson writes a fakeRepository — a plain in-memory struct backed by two maps — rather than reaching for a mocking library. A fake behaves like a real repository (it actually stores what Create writes and returns it from GetByID); a generated mock only records which methods were called, which is a weaker guarantee for testing whether a business rule works correctly.
package service
import (
"context"
"github.com/google/uuid"
"github.com/hoa-platform/backend/services/organization/internal/model"
"github.com/hoa-platform/backend/services/organization/internal/repository"
)
// fakeRepository satisfies Repository entirely in memory. It lives in a
// _test.go file, so it never compiles into the production binary, and it
// never imports pgx, pgxpool, or anything database-specific.
type fakeRepository struct {
bySlug map[string]*model.Organization
byID map[uuid.UUID]*model.Organization
}
func newFakeRepository() *fakeRepository {
return &fakeRepository{
bySlug: make(map[string]*model.Organization),
byID: make(map[uuid.UUID]*model.Organization),
}
}
func (f *fakeRepository) Create(ctx context.Context, o *model.Organization) error {
if _, exists := f.bySlug[o.Slug]; exists {
return repository.ErrDuplicateSlug
}
o.ID = uuid.New()
f.bySlug[o.Slug] = o
f.byID[o.ID] = o
return nil
}
func (f *fakeRepository) GetByID(ctx context.Context, id uuid.UUID) (*model.Organization, error) {
o, ok := f.byID[id]
if !ok {
return nil, repository.ErrNotFound
}
return o, nil
}
func (f *fakeRepository) GetBySlug(ctx context.Context, slug string) (*model.Organization, error) {
o, ok := f.bySlug[slug]
if !ok {
return nil, repository.ErrNotFound
}
return o, nil
}
func (f *fakeRepository) List(ctx context.Context, filter repository.ListFilter) ([]model.Organization, int, error) {
var results []model.Organization
for _, o := range f.byID {
if filter.Status != "" && o.Status != filter.Status {
continue
}
results = append(results, *o)
}
return results, len(results), nil
}
func (f *fakeRepository) Update(ctx context.Context, o *model.Organization) error {
if _, ok := f.byID[o.ID]; !ok {
return repository.ErrNotFound
}
f.byID[o.ID] = o
f.bySlug[o.Slug] = o
return nil
}
This file's only compile-time guarantee is that fakeRepository's six methods match Repository's signatures exactly — Go checks that automatically the first time newFakeRepository() is passed to NewOrganizationService.
Every Business Rule From the Service Layer Lesson, Asserted Directly
import (
"context"
"testing"
"github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)Use require for prerequisites whose failure makes the rest of a test meaningless, and assert for independent result checks that can all report in one run. Testify improves diagnostics; it does not replace table design, fakes, or domain-specific test names.
func validCreateRequest(slug string) dto.CreateOrganizationRequest {
return dto.CreateOrganizationRequest{
Name: "Test Org",
Slug: slug,
RegistrationNumber: "REG-1",
ContactEmail: "admin@test.test",
AddressLine1: "1 Test St",
City: "Austin",
State: "TX",
PostalCode: "73301",
Country: "USA",
}
}
func TestOrganizationService_Create_DuplicateSlugIsConflict(t *testing.T) {
ctx := context.Background()
svc := NewOrganizationService(newFakeRepository())
_, err := svc.Create(ctx, validCreateRequest("maple-grove"))
require.NoError(t, err)
_, err = svc.Create(ctx, validCreateRequest("maple-grove"))
appErr, ok := apperr.As(err)
require.True(t, ok)
assert.Equal(t, apperr.CodeConflict, appErr.Code)
}
func TestOrganizationService_GetByID_UnknownIDIsNotFound(t *testing.T) {
svc := NewOrganizationService(newFakeRepository())
_, err := svc.GetByID(context.Background(), uuid.New())
appErr, ok := apperr.As(err)
require.True(t, ok)
assert.Equal(t, apperr.CodeNotFound, appErr.Code)
}
func TestOrganizationService_Suspend_ActiveToSuspendedSucceeds(t *testing.T) {
ctx := context.Background()
svc := NewOrganizationService(newFakeRepository())
org, err := svc.Create(ctx, validCreateRequest("suspend-me"))
require.NoError(t, err)
got, err := svc.Suspend(ctx, org.ID)
require.NoError(t, err)
assert.Equal(t, model.StatusSuspended, got.Status)
}
func TestOrganizationService_Suspend_ArchivedIsRejected(t *testing.T) {
ctx := context.Background()
svc := NewOrganizationService(newFakeRepository())
org, err := svc.Create(ctx, validCreateRequest("archive-me"))
if err != nil {
t.Fatalf("create: unexpected error: %v", err)
}
if _, err := svc.Archive(ctx, org.ID); err != nil {
t.Fatalf("archive: unexpected error: %v", err)
}
_, err = svc.Suspend(ctx, org.ID)
appErr, ok := apperr.As(err)
if !ok || appErr.Code != apperr.CodeConflict {
t.Fatalf("got %v, want apperr.CodeConflict for archived to suspended", err)
}
}
func TestOrganizationService_Update_OmittedFieldsAreUnchanged(t *testing.T) {
ctx := context.Background()
svc := NewOrganizationService(newFakeRepository())
org, err := svc.Create(ctx, validCreateRequest("update-me"))
require.NoError(t, err)
newEmail := "new-admin@test.test"
got, err := svc.Update(ctx, org.ID, dto.UpdateOrganizationRequest{ContactEmail: &newEmail})
require.NoError(t, err)
assert.Equal(t, newEmail, got.ContactEmail)
assert.Equal(t, "Test Org", got.Name)
}
TestOrganizationService_Update_OmittedFieldsAreUnchanged is the automated version of the DTOs lesson's manual PATCH scenario — a partial update must never silently blank out a field the client didn't mention.
A real integration test (next lesson) proves the SQL behind List is correct. This fake proves something different and equally necessary: that OrganizationService.List correctly passes the caller's filter through and correctly surfaces whatever the repository returns — a concern entirely separate from whether Postgres's WHERE clause is syntactically correct.
Running the Suite — Deliberately Scoped, Never From Repository Root
go.work spans multiple modules with no root go.mod, so a bare go test ./... from the repository root would fail immediately with no main module error. Every test run in this course targets one module explicitly, either with -C or by cding into it first.
Applied exercise
Delete one line from allowedTransitions and watch which specific test catches it
Confirm this lesson's tests actually exercise the transition table from the Service Layer lesson, not just the DTOs and CRUD paths.
- Temporarily remove model.StatusArchived from allowedTransitions[model.StatusActive] in organization_service.go, so active organizations can no longer go directly to archived.
- Run go test ./internal/service/... -v and identify exactly which test fails as a result (it should not be TestOrganizationService_Suspend_ArchivedIsRejected).
- Write one new test, TestOrganizationService_Archive_ActiveToArchivedSucceeds, that would have caught this specific regression, and confirm it fails against the broken table and passes once reverted.
Deliverable
A short note naming which existing test (if any) failed, plus the new test's source and its pass/fail transcript before and after reverting the table.
Completion checks
- The note correctly observes that none of the five tests above actually exercised active-to-archived directly, revealing a real coverage gap.
- The new test is added to the lesson's test file and passes once the table is reverted.
Why does this lesson write a hand-crafted fakeRepository instead of using a code-generated mocking library?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.