diff --git a/.github/workflows/fly.yml b/.github/workflows/fly.yml index 769bf2b..11589b4 100644 --- a/.github/workflows/fly.yml +++ b/.github/workflows/fly.yml @@ -2,11 +2,31 @@ name: Deploy to fly.io on: push: - branches: [ "main" ] + branches: ["main"] + pull_request: + branches: ["main"] jobs: + unittest: + name: Unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 9.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build --verbosity normal + deploy: name: Deploy + if: github.event_name == 'push' + needs: unittest runs-on: ubuntu-latest env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/PointingParty.Client.Tests/GameContextTests.cs b/PointingParty.Client.Tests/GameContextTests.cs new file mode 100644 index 0000000..e0dfd92 --- /dev/null +++ b/PointingParty.Client.Tests/GameContextTests.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; + +namespace PointingParty.Client.Tests; + +public class GameContextTests +{ + [Fact] + public void CreateGame_Returns_GameAggregate() + { + var sut = new GameContext( + Substitute.For>(), + Substitute.For() + ); + + var agg = sut.CreateGame("Game", "Player"); + + Assert.Equal(sut.Game, agg); + Assert.Equal("Game", agg.State.GameId); + Assert.Equal("Player", sut.PlayerName); + } +} diff --git a/PointingParty.Client.Tests/GameStateExtensionsTests.cs b/PointingParty.Client.Tests/GameStateExtensionsTests.cs index ea1057a..1671d9b 100644 --- a/PointingParty.Client.Tests/GameStateExtensionsTests.cs +++ b/PointingParty.Client.Tests/GameStateExtensionsTests.cs @@ -10,7 +10,7 @@ public void Calculates_Average() { var state = new GameState( string.Empty, - new Dictionary() + new Dictionary { { "a", 1 }, { "b", 2 } @@ -19,7 +19,7 @@ public void Calculates_Average() Assert.Equal(1.5, state.AverageVote()); } - + [Theory] [InlineData(VoteStatus.Pending)] [InlineData(VoteStatus.Coffee)] @@ -28,7 +28,7 @@ public void Ignores_Non_Voters(VoteStatus vote) { var state = new GameState( string.Empty, - new Dictionary() + new Dictionary { { "a", 1 }, { "b", 2 }, @@ -47,13 +47,13 @@ public void Returns_Default_When_Nobody_Voted(VoteStatus vote) { var state = new GameState( string.Empty, - new Dictionary() + new Dictionary { { "a", vote }, - { "b", vote}, + { "b", vote } }.ToImmutableDictionary(), true); - + Assert.Equal(default, state.AverageVote()); } } diff --git a/PointingParty.Client.Tests/GameUiTests.razor b/PointingParty.Client.Tests/GameUiTests.cs similarity index 59% rename from PointingParty.Client.Tests/GameUiTests.razor rename to PointingParty.Client.Tests/GameUiTests.cs index 4131087..15144fe 100644 --- a/PointingParty.Client.Tests/GameUiTests.razor +++ b/PointingParty.Client.Tests/GameUiTests.cs @@ -1,21 +1,22 @@ -@using PointingParty.Domain.Events -@inherits TestContext +using AngleSharp.Dom; +using PointingParty.Domain; +using PointingParty.Domain.Events; -@code { - private readonly ITestOutputHelper _testOutputHelper; - private readonly GameAggregate _game; - private readonly IGameContext _gameContext; +namespace PointingParty.Client.Tests; +public class GameUiTests : BunitContext +{ private const string PlayerName = "Player"; private const string GameId = "TestGame"; + private readonly GameAggregate _game; + private readonly IGameContext _gameContext; - public GameUiTests(ITestOutputHelper testOutputHelper) + public GameUiTests() { - _testOutputHelper = testOutputHelper; _game = new GameAggregate(GameId, PlayerName); _gameContext = Substitute.For(); _gameContext.Game = _game; - + JSInterop.Mode = JSRuntimeMode.Loose; } @@ -23,28 +24,28 @@ public GameUiTests(ITestOutputHelper testOutputHelper) public void Renders_PlayerName() { _gameContext.PlayerName.Returns(PlayerName); - - var cut = Render(@); + + var cut = Render(parameters => parameters.Add(p => p.GameContext, _gameContext)); cut.Find("h3").TextContent.MarkupMatches($"Your vote, {PlayerName}:"); } [Fact] public void Publishes_PlayerJoined_Event() { - Render(@); + Render(parameters => parameters.Add(p => p.GameContext, _gameContext)); Assert.Collection(_game.EventsToPublish, e => Assert.IsType(e)); _gameContext.Received(1).PublishEvents(); } - + [Fact] public void Publishes_Vote_Event() { - var cut = Render(@); - + var cut = Render(parameters => parameters.Add(p => p.GameContext, _gameContext)); + _gameContext.ClearReceivedCalls(); _game.EventsToPublish.Clear(); - + cut.FindComponent().Find("button").Click(); Assert.Collection(_game.EventsToPublish, e => @@ -52,24 +53,21 @@ public void Publishes_Vote_Event() Assert.IsType(e); Assert.Equal(((VoteCast)e).Vote, 1); }); - + _gameContext.Received(1).PublishEvents(); } - + [Fact] public void Publishes_VotesShown_Event() { - var cut = Render(@); + var cut = Render(parameters => parameters.Add(p => p.GameContext, _gameContext)); _gameContext.ClearReceivedCalls(); _game.EventsToPublish.Clear(); cut.FindComponent().Find("button").Click(); - Assert.Collection(_game.EventsToPublish, e => - { - Assert.IsType(e); - }); + Assert.Collection(_game.EventsToPublish, e => { Assert.IsType(e); }); _gameContext.Received(1).PublishEvents(); } @@ -79,15 +77,15 @@ public void Shows_Players_Alphabetized() { _game.Handle(new PlayerJoinedGame(GameId, "Player Two")); _game.Handle(new PlayerJoinedGame(GameId, "Player Three")); - - var cut = Render(@); - var results = cut.FindAll(@"table[data-testid=""results""] tbody tr td:first-child"); + var cut = Render(parameters => parameters.Add(p => p.GameContext, _gameContext)); + + var results = cut.FindAll("""table[data-testid="results"] tbody tr td:first-child"""); Assert.Collection(results, - e => { Assert.Equal(e.GetInnerText(), PlayerName); }, - e => { Assert.Equal(e.GetInnerText(), "Player Three"); }, - e => { Assert.Equal(e.GetInnerText(), "Player Two"); } + e => { Assert.Equal(PlayerName, e.GetInnerText()); }, + e => { Assert.Equal("Player Three", e.GetInnerText()); }, + e => { Assert.Equal("Player Two", e.GetInnerText()); } ); } @@ -96,12 +94,12 @@ public void Hides_Votes_By_Default() { _game.Handle(new PlayerJoinedGame(GameId, "Player Two")); _game.Handle(new VoteCast(GameId, "Player Two", 8)); - - var cut = Render(@); - var results = cut.Find(@"[data-testid=""vote-for-Player Two""]"); - Assert.Equal(results.GetInnerText(), "Voted!"); + + var cut = Render(parameters => parameters.Add(p => p.GameContext, _gameContext)); + var results = cut.Find("""[data-testid="vote-for-Player Two"]"""); + Assert.Equal("Voted!", results.GetInnerText()); } - + [Fact] public void Shows_Votes_After_VotesShown_Event() { @@ -109,8 +107,8 @@ public void Shows_Votes_After_VotesShown_Event() _game.Handle(new VoteCast(GameId, "Player Two", 8)); _game.Handle(new VotesShown(GameId)); - var cut = Render(@); - var results = cut.Find(@"[data-testid=""vote-for-Player Two""]"); - Assert.Equal(results.GetInnerText(), "8"); + var cut = Render(parameters => parameters.Add(p => p.GameContext, _gameContext)); + var results = cut.Find("""[data-testid="vote-for-Player Two"]"""); + Assert.Equal("8", results.GetInnerText()); } -} +} \ No newline at end of file diff --git a/PointingParty.Client.Tests/GlobalUsings.cs b/PointingParty.Client.Tests/GlobalUsings.cs index 8c927eb..30757b5 100644 --- a/PointingParty.Client.Tests/GlobalUsings.cs +++ b/PointingParty.Client.Tests/GlobalUsings.cs @@ -1 +1,3 @@ +global using Bunit; +global using NSubstitute; global using Xunit; \ No newline at end of file diff --git a/PointingParty.Client.Tests/Pages/GameTests.cs b/PointingParty.Client.Tests/Pages/GameTests.cs new file mode 100644 index 0000000..e539c16 --- /dev/null +++ b/PointingParty.Client.Tests/Pages/GameTests.cs @@ -0,0 +1,78 @@ +using Bunit.TestDoubles; +using Microsoft.Extensions.DependencyInjection; +using PointingParty.Client.Pages; +using PointingParty.Domain; + +namespace PointingParty.Client.Tests.Pages; + +public class GameTests : BunitContext +{ + private readonly IGameContext _gameContext; + + public GameTests() + { + _gameContext = Substitute.For(); + Services.AddTransient(_ => _gameContext); + ComponentFactories.AddStub(); + } + + [Fact] + public void WhenSubmittingForm_CreatesGame() + { + var cut = Render(parameters => + parameters.Add(p => p.GameId, "Game")); + + var nameInput = cut.Find("""input[placeholder="Player Name"]"""); + nameInput.Change("Player"); + + cut.Find("button").Click(); + + _gameContext.Received(1).CreateGame("Game", "Player"); + _gameContext.Received(1).Initialize(Arg.Any()); + } + + [Fact] + public void WithGame_RendersGameUi() + { + _gameContext.Game.Returns(new GameAggregate()); + + var cut = Render(parameters => + parameters.Add(p => p.GameId, "Game")); + + Assert.NotNull(cut.FindComponent>()); + } + + [Fact] + public void WithGame_RendersTitle() + { + _gameContext.Game.Returns(new GameAggregate()); + + var cut = Render(parameters => + parameters.Add(p => p.GameId, "Game")); + + var h2 = cut.Find("h2"); + + Assert.Equal("Pointing Party: Game", h2.TextContent); + } + + [Fact] + public void WithPlayerName_CreatesGame_And_UpdatesUrl() + { + _gameContext.Game.Returns(new GameAggregate()); + var navMan = Services.GetRequiredService(); + + navMan.NavigateTo("/Game/Game?PlayerName=Player"); + + Render(parameters => { parameters.Add(p => p.GameId, "Game"); }); + + Assert.Equal("http://localhost/Game/Game", navMan.Uri); + + _gameContext.Received(1).CreateGame("Game", "Player"); + _gameContext.Received(1).Initialize(Arg.Any()); + } + + protected override void Dispose(bool disposing) + { + // Override BunitContext.Dispose to avoid calling sync Dispose on the Services + } +} diff --git a/PointingParty.Client.Tests/PointingParty.Client.Tests.csproj b/PointingParty.Client.Tests/PointingParty.Client.Tests.csproj index d72b932..00cc3cf 100644 --- a/PointingParty.Client.Tests/PointingParty.Client.Tests.csproj +++ b/PointingParty.Client.Tests/PointingParty.Client.Tests.csproj @@ -10,10 +10,14 @@ - + - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -25,7 +29,11 @@ - + + + + + diff --git a/PointingParty.Client.Tests/_Imports.razor b/PointingParty.Client.Tests/_Imports.razor deleted file mode 100644 index 1e63b14..0000000 --- a/PointingParty.Client.Tests/_Imports.razor +++ /dev/null @@ -1,12 +0,0 @@ -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.AspNetCore.Components.Web -@using Microsoft.JSInterop -@using Microsoft.Extensions.DependencyInjection -@using AngleSharp.Dom -@using Bunit -@using Bunit.TestDoubles -@using NSubstitute -@using Xunit -@using Xunit.Abstractions -@using PointingParty.Client -@using PointingParty.Domain diff --git a/README.md b/README.md index 5737ae3..8d00e53 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ and the application is built to be usable from all platforms and devices. The project does not rely on a backend database. Instead, each client maintains its own game state based on events published through the central SignalR hub. -This project uses .NET 8, Blazor, and Tailwind CSS. +This project uses .NET 9, Blazor, and Tailwind CSS. ## Try it out @@ -20,4 +20,13 @@ free to use it in your team. Simply clone and run. Pull requests are welcome! +The project contains some unit tests, which can be executed with `dotnet test` or from the IDE. In addition, +a Playwright test suite can be found in the `e2e/` directory. To run the end-to-end tests in development, +start the application and run the following command in the `e2e/` directory: + +``` +npm install +BASE_URL=http://localhost:5174 npx playwright test --ui +``` + © Martijn Storck