From 4090f4ac05bf9311fc97f6cd4535d58ae35f670e Mon Sep 17 00:00:00 2001 From: Bowen Date: Tue, 31 Mar 2026 11:54:14 +0800 Subject: [PATCH] test: add comprehensive event feature tests and refactor test helpers --- tests/feature/database_commands_test.go | 131 -------- tests/feature/db_test.go | 103 ++++++- tests/feature/event_test.go | 381 +++++++++++++++++++++++- tests/feature/migration_test.go | 14 +- tests/test_case.go | 15 + 5 files changed, 496 insertions(+), 148 deletions(-) delete mode 100644 tests/feature/database_commands_test.go diff --git a/tests/feature/database_commands_test.go b/tests/feature/database_commands_test.go deleted file mode 100644 index 093e276..0000000 --- a/tests/feature/database_commands_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package feature - -import ( - "io" - "os" - "testing" - - "github.com/goravel/framework/support/color" - "github.com/goravel/framework/support/file" - "github.com/goravel/framework/support/path" - "github.com/stretchr/testify/suite" - - "goravel/app/facades" - "goravel/app/models" - "goravel/tests" -) - -type DatabaseCommandsTestSuite struct { - suite.Suite - tests.TestCase -} - -func TestDatabaseCommandsTestSuite(t *testing.T) { - suite.Run(t, &DatabaseCommandsTestSuite{}) -} - -func (s *DatabaseCommandsTestSuite) SetupTest() { - s.RefreshDatabase() -} - -func (s *DatabaseCommandsTestSuite) TestCommandDBSeed() { - s.NoError(facades.Artisan().Call("--no-ansi db:seed")) - - var user models.User - s.NoError(facades.Orm().Query().Where("mail", "migration@goravel.dev").FirstOrFail(&user)) - s.Equal("migration", user.Name) -} - -func (s *DatabaseCommandsTestSuite) TestCommandDBShow() { - output := s.captureArtisanOutput("--no-ansi db:show") - s.Contains(output, "Database") - s.Contains(output, "Tables") - s.Contains(output, "users") -} - -func (s *DatabaseCommandsTestSuite) TestCommandDBTable() { - output := s.captureArtisanOutput("--no-ansi db:table users") - s.Contains(output, "users") - s.Contains(output, "Columns") - s.Contains(output, "id") -} - -func (s *DatabaseCommandsTestSuite) TestCommandDBWipe() { - s.NoError(facades.Artisan().Call("--no-ansi db:wipe")) - - s.False(facades.Schema().HasTable("users")) - s.False(facades.Schema().HasTable("jobs")) - s.False(facades.Schema().HasTable("failed_jobs")) -} - -func (s *DatabaseCommandsTestSuite) TestCommandMakeModel() { - modelPath := path.Model("test_db_command_model.go") - s.NoError(file.Remove(modelPath)) - s.T().Cleanup(func() { - s.NoError(file.Remove(modelPath)) - }) - - s.NoError(facades.Artisan().Call("--no-ansi make:model TestDbCommandModel")) - s.True(file.Exists(modelPath)) - s.True(file.Contains(modelPath, "type TestDbCommandModel struct")) -} - -func (s *DatabaseCommandsTestSuite) TestCommandMakeObserver() { - observerPath := path.App("observers", "test_db_command_observer.go") - s.NoError(file.Remove(observerPath)) - s.T().Cleanup(func() { - s.NoError(file.Remove(observerPath)) - }) - - s.NoError(facades.Artisan().Call("--no-ansi make:observer TestDbCommandObserver")) - s.True(file.Exists(observerPath)) - s.True(file.Contains(observerPath, "type TestDbCommandObserver struct")) -} - -func (s *DatabaseCommandsTestSuite) TestCommandMakeFactory() { - factoryPath := path.Database("factories", "test_db_command_factory.go") - s.NoError(file.Remove(factoryPath)) - s.T().Cleanup(func() { - s.NoError(file.Remove(factoryPath)) - }) - - s.NoError(facades.Artisan().Call("--no-ansi make:factory TestDbCommandFactory")) - s.True(file.Exists(factoryPath)) - s.True(file.Contains(factoryPath, "type TestDbCommandFactory struct")) -} - -func (s *DatabaseCommandsTestSuite) TestCommandMakeSeeder() { - seederPath := path.Database("seeders", "test_db_command_seeder.go") - seedersBootstrapPath := path.Bootstrap("seeders.go") - - seedersBootstrapContent, err := os.ReadFile(seedersBootstrapPath) - if err != nil { - s.T().Fatalf("read %s failed: %v", seedersBootstrapPath, err) - } - - s.NotContains(string(seedersBootstrapContent), "&seeders.TestDbCommandSeeder{}") - - s.NoError(file.Remove(seederPath)) - - s.T().Cleanup(func() { - s.NoError(file.Remove(seederPath)) - if err := os.WriteFile(seedersBootstrapPath, seedersBootstrapContent, 0o644); err != nil { - s.T().Fatalf("restore %s failed: %v", seedersBootstrapPath, err) - } - }) - - s.NoError(facades.Artisan().Call("--no-ansi make:seeder TestDbCommandSeeder")) - s.True(file.Exists(seederPath)) - s.True(file.Contains(seederPath, "type TestDbCommandSeeder struct")) - - updatedSeedersBootstrap, err := os.ReadFile(seedersBootstrapPath) - s.Require().NoError(err) - - s.Contains(string(updatedSeedersBootstrap), "&seeders.TestDbCommandSeeder{}") -} - -func (s *DatabaseCommandsTestSuite) captureArtisanOutput(command string) string { - return color.CaptureOutput(func(_ io.Writer) { - s.NoError(facades.Artisan().Call(command)) - }) -} diff --git a/tests/feature/db_test.go b/tests/feature/db_test.go index 34bbc98..9451fc2 100644 --- a/tests/feature/db_test.go +++ b/tests/feature/db_test.go @@ -3,12 +3,16 @@ package feature import ( + "os" "testing" "github.com/goravel/framework/support/carbon" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" "github.com/stretchr/testify/suite" "goravel/app/facades" + "goravel/app/models" "goravel/tests" ) @@ -52,7 +56,104 @@ func (s *DBTestSuite) TestCRUD() { s.Equal(int64(1), result.RowsAffected) } -// TODO use orm.BaseModel when https://github.com/goravel/framework/pull/976 is merged +func (s *DBTestSuite) TestCommandDBSeed() { + s.NoError(facades.Artisan().Call("--no-ansi db:seed")) + + var user models.User + s.NoError(facades.Orm().Query().Where("mail", "migration@goravel.dev").FirstOrFail(&user)) + s.Equal("migration", user.Name) +} + +func (s *DBTestSuite) TestCommandDBShow() { + output, err := s.CaptureArtisanOutput("--no-ansi db:show") + s.NoError(err) + s.Contains(output, "Database") + s.Contains(output, "Tables") + s.Contains(output, "users") +} + +func (s *DBTestSuite) TestCommandDBTable() { + output, err := s.CaptureArtisanOutput("--no-ansi db:table users") + s.NoError(err) + s.Contains(output, "users") + s.Contains(output, "Columns") + s.Contains(output, "id") +} + +func (s *DBTestSuite) TestCommandDBWipe() { + s.NoError(facades.Artisan().Call("--no-ansi db:wipe")) + + s.False(facades.Schema().HasTable("users")) + s.False(facades.Schema().HasTable("jobs")) + s.False(facades.Schema().HasTable("failed_jobs")) +} + +func (s *DBTestSuite) TestCommandMakeModel() { + modelPath := path.Model("test_db_command_model.go") + s.NoError(file.Remove(modelPath)) + s.T().Cleanup(func() { + s.NoError(file.Remove(modelPath)) + }) + + s.NoError(facades.Artisan().Call("--no-ansi make:model TestDbCommandModel")) + s.True(file.Exists(modelPath)) + s.True(file.Contains(modelPath, "type TestDbCommandModel struct")) +} + +func (s *DBTestSuite) TestCommandMakeObserver() { + observerPath := path.App("observers", "test_db_command_observer.go") + s.NoError(file.Remove(observerPath)) + s.T().Cleanup(func() { + s.NoError(file.Remove(observerPath)) + }) + + s.NoError(facades.Artisan().Call("--no-ansi make:observer TestDbCommandObserver")) + s.True(file.Exists(observerPath)) + s.True(file.Contains(observerPath, "type TestDbCommandObserver struct")) +} + +func (s *DBTestSuite) TestCommandMakeFactory() { + factoryPath := path.Database("factories", "test_db_command_factory.go") + s.NoError(file.Remove(factoryPath)) + s.T().Cleanup(func() { + s.NoError(file.Remove(factoryPath)) + }) + + s.NoError(facades.Artisan().Call("--no-ansi make:factory TestDbCommandFactory")) + s.True(file.Exists(factoryPath)) + s.True(file.Contains(factoryPath, "type TestDbCommandFactory struct")) +} + +func (s *DBTestSuite) TestCommandMakeSeeder() { + seederPath := path.Database("seeders", "test_db_command_seeder.go") + seedersBootstrapPath := path.Bootstrap("seeders.go") + + seedersBootstrapContent, err := os.ReadFile(seedersBootstrapPath) + if err != nil { + s.T().Fatalf("read %s failed: %v", seedersBootstrapPath, err) + } + + s.NotContains(string(seedersBootstrapContent), "&seeders.TestDbCommandSeeder{}") + + s.NoError(file.Remove(seederPath)) + + s.T().Cleanup(func() { + s.NoError(file.Remove(seederPath)) + if err := os.WriteFile(seedersBootstrapPath, seedersBootstrapContent, 0o644); err != nil { + s.T().Fatalf("restore %s failed: %v", seedersBootstrapPath, err) + } + }) + + s.NoError(facades.Artisan().Call("--no-ansi make:seeder TestDbCommandSeeder")) + s.True(file.Exists(seederPath)) + s.True(file.Contains(seederPath, "type TestDbCommandSeeder struct")) + + updatedSeedersBootstrap, err := os.ReadFile(seedersBootstrapPath) + s.Require().NoError(err) + + s.Contains(string(updatedSeedersBootstrap), "&seeders.TestDbCommandSeeder{}") +} + type User struct { ID uint `db:"id"` Name string `db:"name"` diff --git a/tests/feature/event_test.go b/tests/feature/event_test.go index 2075655..b354a10 100644 --- a/tests/feature/event_test.go +++ b/tests/feature/event_test.go @@ -1,30 +1,399 @@ package feature import ( + "errors" + "fmt" + "maps" + "os" + "sync" + "sync/atomic" "testing" "time" "github.com/goravel/framework/contracts/event" - "github.com/stretchr/testify/assert" + frameworkerrors "github.com/goravel/framework/errors" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" + "github.com/goravel/framework/support/str" + "github.com/stretchr/testify/suite" "goravel/app/events" "goravel/app/facades" "goravel/app/listeners" + "goravel/tests" ) -func TestEvent(t *testing.T) { - assert.NoError(t, facades.Event().Job(&events.OrderShipped{}, []event.Arg{ +type EventTestSuite struct { + suite.Suite + tests.TestCase + counter uint64 +} + +func TestEventTestSuite(t *testing.T) { + suite.Run(t, &EventTestSuite{}) +} + +func (s *EventTestSuite) SetupTest() { + listeners.TestResultOfSendShipmentNotification = nil + + // Snapshot the event registry so any events registered during this test + // can be removed in cleanup, preventing cross-test pollution. + snapshot := maps.Clone(facades.Event().GetEvents()) + s.T().Cleanup(func() { + registry := facades.Event().GetEvents() + for k := range registry { + if _, ok := snapshot[k]; !ok { + delete(registry, k) + } + } + }) +} + +func (s *EventTestSuite) TestDispatchBootstrappedEvents() { + s.NoError(facades.Event().Job(&events.OrderShipped{}, []event.Arg{ {Type: "string", Value: "I'm OrderShipped"}, }).Dispatch()) - assert.NoError(t, facades.Event().Job(&events.OrderCanceled{}, []event.Arg{ + s.NoError(facades.Event().Job(&events.OrderCanceled{}, []event.Arg{ {Type: "string", Value: "I'm OrderCanceled"}, }).Dispatch()) - time.Sleep(1 * time.Second) + s.True(waitUntil(3*time.Second, 20*time.Millisecond, func() bool { + return len(listeners.TestResultOfSendShipmentNotification) == 2 + })) - assert.ElementsMatch(t, []string{ + s.ElementsMatch([]string{ "I'm OrderShipped", "I'm OrderCanceled", }, listeners.TestResultOfSendShipmentNotification) } + +func (s *EventTestSuite) TestDispatchUnregisteredEvent() { + eventInstance := &integrationEvent{ + handle: func(args []event.Arg) ([]event.Arg, error) { + return args, nil + }, + } + + err := facades.Event().Job(eventInstance, nil).Dispatch() + + s.Equal(frameworkerrors.EventListenerNotBind.Args(eventInstance), err) +} + +func (s *EventTestSuite) TestDispatchReturnsEventHandleError() { + expectedErr := errors.New("event handle error") + eventInstance := &integrationEvent{ + handle: func(args []event.Arg) ([]event.Arg, error) { + return nil, expectedErr + }, + } + capture := &listenerCapture{} + listenerInstance := &integrationListener{ + signature: s.uniqueName("event_handle_error_listener"), + queueConfig: event.Queue{Enable: false}, + capture: capture, + } + facades.Event().Register(map[event.Event][]event.Listener{ + eventInstance: { + listenerInstance, + }, + }) + + err := facades.Event().Job(eventInstance, []event.Arg{ + {Type: "string", Value: "test"}, + }).Dispatch() + + s.Equal(expectedErr, err) + s.Empty(capture.Handled()) +} + +func (s *EventTestSuite) TestDispatchSyncListenerWithTransformedArgs() { + eventInstance := &integrationEvent{ + handle: func(args []event.Arg) ([]event.Arg, error) { + return []event.Arg{ + {Type: "string", Value: castString(args[0].Value) + "_transformed"}, + {Type: "int", Value: 2}, + }, nil + }, + } + capture := &listenerCapture{} + listenerInstance := &integrationListener{ + signature: s.uniqueName("sync_listener"), + queueConfig: event.Queue{Enable: false}, + capture: capture, + } + facades.Event().Register(map[event.Event][]event.Listener{ + eventInstance: { + listenerInstance, + }, + }) + + err := facades.Event().Job(eventInstance, []event.Arg{ + {Type: "string", Value: "goravel"}, + }).Dispatch() + + s.NoError(err) + s.Equal([][]any{ + {"goravel_transformed", 2}, + }, capture.Handled()) + s.Equal(1, capture.QueueCallCount()) +} + +func (s *EventTestSuite) TestDispatchStopsAfterListenerError() { + expectedErr := errors.New("listener handle error") + eventInstance := &integrationEvent{ + handle: func(args []event.Arg) ([]event.Arg, error) { + return args, nil + }, + } + failedCapture := &listenerCapture{} + skippedCapture := &listenerCapture{} + failedListener := &integrationListener{ + signature: s.uniqueName("failed_listener"), + queueConfig: event.Queue{Enable: false}, + handleErr: expectedErr, + capture: failedCapture, + } + skippedListener := &integrationListener{ + signature: s.uniqueName("skipped_listener"), + queueConfig: event.Queue{Enable: false}, + capture: skippedCapture, + } + facades.Event().Register(map[event.Event][]event.Listener{ + eventInstance: { + failedListener, + skippedListener, + }, + }) + + err := facades.Event().Job(eventInstance, []event.Arg{ + {Type: "string", Value: "should stop"}, + }).Dispatch() + + s.Equal(expectedErr, err) + s.Len(failedCapture.Handled(), 1) + s.Empty(skippedCapture.Handled()) +} + +func (s *EventTestSuite) TestDispatchQueuedListenerEventually() { + eventInstance := &integrationEvent{ + handle: func(args []event.Arg) ([]event.Arg, error) { + return args, nil + }, + } + capture := &listenerCapture{} + listenerInstance := &integrationListener{ + signature: s.uniqueName("queued_listener"), + queueConfig: event.Queue{ + Enable: true, + }, + capture: capture, + } + facades.Event().Register(map[event.Event][]event.Listener{ + eventInstance: { + listenerInstance, + }, + }) + + err := facades.Event().Job(eventInstance, []event.Arg{ + {Type: "string", Value: "queued"}, + }).Dispatch() + + s.NoError(err) + s.True(waitUntil(5*time.Second, 20*time.Millisecond, func() bool { + return len(capture.Handled()) == 1 + })) + s.Equal([][]any{ + {"queued"}, + }, capture.Handled()) + s.Equal(1, capture.QueueCallCount()) +} + +func (s *EventTestSuite) TestCommandMakeEvent() { + eventName := s.uniqueName("EventFeature") + nestedPackage := s.uniqueName("EventFeatureNested") + nestedEventName := s.uniqueName("GeneratedEvent") + eventPath := path.App("events", str.Of(eventName).Snake().String()+".go") + nestedDir := path.App("events", nestedPackage) + nestedPath := path.App("events", nestedPackage, str.Of(nestedEventName).Snake().String()+".go") + + s.NoError(os.RemoveAll(eventPath)) + s.NoError(os.RemoveAll(nestedDir)) + s.T().Cleanup(func() { + s.NoError(os.RemoveAll(eventPath)) + s.NoError(os.RemoveAll(nestedDir)) + }) + + s.NoError(facades.Artisan().Call("--no-ansi make:event " + eventName)) + s.True(file.Exists(eventPath)) + s.True(file.Contains(eventPath, "type "+eventName+" struct {")) + s.True(file.Contains(eventPath, "func (receiver *"+eventName+") Handle(args []event.Arg) ([]event.Arg, error)")) + + originalContent, err := os.ReadFile(eventPath) + s.NoError(err) + + output, err := s.CaptureArtisanOutput("--no-ansi make:event " + eventName) + s.NoError(err) + s.Contains(output, "already exists") + + currentContent, err := os.ReadFile(eventPath) + s.NoError(err) + s.Equal(string(originalContent), string(currentContent)) + + s.NoError(facades.Artisan().Call("--no-ansi make:event " + nestedPackage + "/" + nestedEventName)) + s.True(file.Exists(nestedPath)) + s.True(file.Contains(nestedPath, "package "+nestedPackage)) + s.True(file.Contains(nestedPath, "type "+nestedEventName+" struct {")) +} + +func (s *EventTestSuite) TestCommandMakeListener() { + listenerName := s.uniqueName("ListenerFeature") + nestedPackage := s.uniqueName("ListenerFeatureNested") + nestedListenerName := s.uniqueName("GeneratedListener") + listenerPath := path.App("listeners", str.Of(listenerName).Snake().String()+".go") + nestedDir := path.App("listeners", nestedPackage) + nestedPath := path.App("listeners", nestedPackage, str.Of(nestedListenerName).Snake().String()+".go") + + s.NoError(os.RemoveAll(listenerPath)) + s.NoError(os.RemoveAll(nestedDir)) + s.T().Cleanup(func() { + s.NoError(os.RemoveAll(listenerPath)) + s.NoError(os.RemoveAll(nestedDir)) + }) + + s.NoError(facades.Artisan().Call("--no-ansi make:listener " + listenerName)) + s.True(file.Exists(listenerPath)) + s.True(file.Contains(listenerPath, "type "+listenerName+" struct {")) + s.True(file.Contains(listenerPath, "func (receiver *"+listenerName+") Signature() string {")) + s.True(file.Contains(listenerPath, `return "`+str.Of(listenerName).Snake().String()+`"`)) + + originalContent, err := os.ReadFile(listenerPath) + s.NoError(err) + + output, err := s.CaptureArtisanOutput("--no-ansi make:listener " + listenerName) + s.NoError(err) + s.Contains(output, "already exists") + + currentContent, err := os.ReadFile(listenerPath) + s.NoError(err) + s.Equal(string(originalContent), string(currentContent)) + + s.NoError(facades.Artisan().Call("--no-ansi make:listener " + nestedPackage + "/" + nestedListenerName)) + s.True(file.Exists(nestedPath)) + s.True(file.Contains(nestedPath, "package "+nestedPackage)) + s.True(file.Contains(nestedPath, "type "+nestedListenerName+" struct {")) + s.True(file.Contains(nestedPath, `return "`+str.Of(nestedListenerName).Snake().String()+`"`)) +} + +func (s *EventTestSuite) uniqueName(prefix string) string { + return fmt.Sprintf("%s%d", prefix, atomic.AddUint64(&s.counter, 1)) +} + +type integrationEvent struct { + handle func(args []event.Arg) ([]event.Arg, error) +} + +func (receiver *integrationEvent) Handle(args []event.Arg) ([]event.Arg, error) { + if receiver.handle == nil { + return args, nil + } + + return receiver.handle(args) +} + +type integrationListener struct { + signature string + queueConfig event.Queue + handleErr error + capture *listenerCapture +} + +func (receiver *integrationListener) Signature() string { + return receiver.signature +} + +func (receiver *integrationListener) Queue(args ...any) event.Queue { + if receiver.capture != nil { + receiver.capture.AddQueueArgs(args) + } + + return receiver.queueConfig +} + +func (receiver *integrationListener) Handle(args ...any) error { + if receiver.capture != nil { + receiver.capture.AddHandled(args) + } + + return receiver.handleErr +} + +type listenerCapture struct { + mu sync.Mutex + handled [][]any + queueArgs [][]any +} + +func (receiver *listenerCapture) AddHandled(args []any) { + receiver.mu.Lock() + defer receiver.mu.Unlock() + + receiver.handled = append(receiver.handled, copyAnySlice(args)) +} + +func (receiver *listenerCapture) AddQueueArgs(args []any) { + receiver.mu.Lock() + defer receiver.mu.Unlock() + + receiver.queueArgs = append(receiver.queueArgs, copyAnySlice(args)) +} + +func (receiver *listenerCapture) Handled() [][]any { + receiver.mu.Lock() + defer receiver.mu.Unlock() + + result := make([][]any, len(receiver.handled)) + for i, args := range receiver.handled { + result[i] = copyAnySlice(args) + } + + return result +} + +func (receiver *listenerCapture) QueueCallCount() int { + receiver.mu.Lock() + defer receiver.mu.Unlock() + + return len(receiver.queueArgs) +} + +func copyAnySlice(args []any) []any { + copyArgs := make([]any, len(args)) + copy(copyArgs, args) + + return copyArgs +} + +func castString(value any) string { + result, ok := value.(string) + if ok { + return result + } + + return fmt.Sprintf("%v", value) +} + +// waitUntil polls until condition returns true or timeout occurs. +func waitUntil(timeout, interval time.Duration, condition func() bool) bool { + deadline := time.Now().Add(timeout) + for { + if condition() { + return true + } + if time.Now().After(deadline) { + return false + } + + time.Sleep(interval) + } +} diff --git a/tests/feature/migration_test.go b/tests/feature/migration_test.go index b00bfbc..87eaacb 100644 --- a/tests/feature/migration_test.go +++ b/tests/feature/migration_test.go @@ -1,7 +1,6 @@ package feature import ( - "io" "os" "regexp" "strings" @@ -10,7 +9,6 @@ import ( "github.com/spf13/cast" - "github.com/goravel/framework/support/color" "github.com/goravel/framework/support/file" "github.com/goravel/framework/support/path" "github.com/goravel/mysql" @@ -194,7 +192,8 @@ func (s *MigrationTestSuite) TestCommandMigrateRollback() { } func (s *MigrationTestSuite) TestCommandMigrateStatus() { - ranOutput := s.captureArtisanOutput("--no-ansi migrate:status") + ranOutput, err := s.CaptureArtisanOutput("--no-ansi migrate:status") + s.NoError(err) s.Contains(ranOutput, "Migration name") s.Contains(ranOutput, "Batch / Status") s.Contains(ranOutput, "20210101000001_create_users_table") @@ -205,7 +204,8 @@ func (s *MigrationTestSuite) TestCommandMigrateStatus() { s.NoError(facades.Artisan().Call("--no-ansi migrate:reset")) - pendingOutput := s.captureArtisanOutput("--no-ansi migrate:status") + pendingOutput, err := s.CaptureArtisanOutput("--no-ansi migrate:status") + s.NoError(err) s.Contains(pendingOutput, "Migration name") s.Contains(pendingOutput, "Batch / Status") s.Contains(pendingOutput, "20210101000001_create_users_table") @@ -302,12 +302,6 @@ func (s *MigrationTestSuite) columnExists(table, column string) bool { return facades.Schema().HasColumn(table, column) } -func (s *MigrationTestSuite) captureArtisanOutput(command string) string { - return color.CaptureOutput(func(_ io.Writer) { - s.NoError(facades.Artisan().Call(command)) - }) -} - func (s *MigrationTestSuite) listMigrationFiles() map[string]struct{} { migrationDir := path.Migration() entries, err := os.ReadDir(migrationDir) diff --git a/tests/test_case.go b/tests/test_case.go index f13aa47..1a90d2f 100644 --- a/tests/test_case.go +++ b/tests/test_case.go @@ -1,8 +1,12 @@ package tests import ( + "io" + + "github.com/goravel/framework/support/color" "github.com/goravel/framework/testing" + "goravel/app/facades" "goravel/bootstrap" ) @@ -13,3 +17,14 @@ func init() { type TestCase struct { testing.TestCase } + +// CaptureArtisanOutput runs the given artisan command and returns its output +// along with any error. Callers are responsible for asserting the error. +func (r *TestCase) CaptureArtisanOutput(command string) (string, error) { + var err error + output := color.CaptureOutput(func(_ io.Writer) { + err = facades.Artisan().Call(command) + }) + + return output, err +}