Skip to content

Commit a263750

Browse files
Add cross-platform unit tests with MSTest framework (#21)
* Initial plan * Add test project with cross-platform unit tests Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Update build workflow to add cross-platform test job Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Improve Runner class to capture stderr and properly dispose Process Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Add explicit permissions to workflow jobs for security Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Split tests into ProgramTests and IntegrationTests Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Use COMSPEC environment variable for Windows shell path Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
1 parent 524c7e4 commit a263750

File tree

7 files changed

+452
-0
lines changed

7 files changed

+452
-0
lines changed

.github/workflows/build.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,46 @@ on:
99
type: string
1010

1111
jobs:
12+
test:
13+
name: Test (${{ matrix.os }})
14+
runs-on: ${{ matrix.os }}
15+
permissions:
16+
contents: read
17+
strategy:
18+
matrix:
19+
os: [ubuntu-latest, windows-latest, macos-latest]
20+
steps:
21+
22+
- uses: actions/checkout@v6
23+
24+
- name: Setup dotnet
25+
uses: actions/setup-dotnet@v5
26+
with:
27+
dotnet-version: |
28+
8.x
29+
9.x
30+
10.x
31+
32+
- name: Build
33+
run: >
34+
dotnet build
35+
--configuration Release
36+
--property:Version=${{ inputs.semver }}
37+
--property:InformationalVersion=${{ inputs.semver }}
38+
--property:AssemblyVersion=${{ inputs.winver }}
39+
40+
- name: Test
41+
run: >
42+
dotnet test
43+
--configuration Release
44+
--no-build
45+
1246
build:
47+
name: Build Artifacts
48+
needs: test
1349
runs-on: ubuntu-latest
50+
permissions:
51+
contents: read
1452
steps:
1553

1654
- uses: actions/checkout@v6

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
.vs
22
*.bak
33
node_modules
4+
bin/
5+
obj/
6+
TestResults/
7+
drop/

DemaConsulting.DotnetToolWrapper.sln

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ VisualStudioVersion = 17.9.34622.214
55
MinimumVisualStudioVersion = 10.0.40219.1
66
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DemaConsulting.DotnetToolWrapper", "src\DemaConsulting.DotnetToolWrapper\DemaConsulting.DotnetToolWrapper.csproj", "{9381FBF3-DCAE-4D2E-9F20-341FDDD38537}"
77
EndProject
8+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DemaConsulting.DotnetToolWrapper.Tests", "test\DemaConsulting.DotnetToolWrapper.Tests\DemaConsulting.DotnetToolWrapper.Tests.csproj", "{8E5F3A42-1D5B-4C9E-8F1D-2C3F4D5E6F7A}"
9+
EndProject
810
Global
911
GlobalSection(SolutionConfigurationPlatforms) = preSolution
1012
Debug|Any CPU = Debug|Any CPU
@@ -15,6 +17,10 @@ Global
1517
{9381FBF3-DCAE-4D2E-9F20-341FDDD38537}.Debug|Any CPU.Build.0 = Debug|Any CPU
1618
{9381FBF3-DCAE-4D2E-9F20-341FDDD38537}.Release|Any CPU.ActiveCfg = Release|Any CPU
1719
{9381FBF3-DCAE-4D2E-9F20-341FDDD38537}.Release|Any CPU.Build.0 = Release|Any CPU
20+
{8E5F3A42-1D5B-4C9E-8F1D-2C3F4D5E6F7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
21+
{8E5F3A42-1D5B-4C9E-8F1D-2C3F4D5E6F7A}.Debug|Any CPU.Build.0 = Debug|Any CPU
22+
{8E5F3A42-1D5B-4C9E-8F1D-2C3F4D5E6F7A}.Release|Any CPU.ActiveCfg = Release|Any CPU
23+
{8E5F3A42-1D5B-4C9E-8F1D-2C3F4D5E6F7A}.Release|Any CPU.Build.0 = Release|Any CPU
1824
EndGlobalSection
1925
GlobalSection(SolutionProperties) = preSolution
2026
HideSolutionNode = FALSE
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
5+
<LangVersion>12</LangVersion>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
<IsPackable>false</IsPackable>
9+
<IsTestProject>true</IsTestProject>
10+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
11+
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
12+
<EnableNETAnalyzers>true</EnableNETAnalyzers>
13+
</PropertyGroup>
14+
15+
<ItemGroup>
16+
<PackageReference Include="coverlet.collector" Version="6.0.2">
17+
<PrivateAssets>all</PrivateAssets>
18+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
19+
</PackageReference>
20+
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0">
21+
<PrivateAssets>all</PrivateAssets>
22+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
23+
</PackageReference>
24+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
25+
<PackageReference Include="MSTest.TestAdapter" Version="3.7.0" />
26+
<PackageReference Include="MSTest.TestFramework" Version="3.7.0" />
27+
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.4.0.108396">
28+
<PrivateAssets>all</PrivateAssets>
29+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
30+
</PackageReference>
31+
</ItemGroup>
32+
33+
<ItemGroup>
34+
<ProjectReference Include="..\..\src\DemaConsulting.DotnetToolWrapper\DemaConsulting.DotnetToolWrapper.csproj">
35+
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
36+
</ProjectReference>
37+
</ItemGroup>
38+
39+
</Project>
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
using System.Runtime.InteropServices;
2+
using System.Text.Json;
3+
using Microsoft.VisualStudio.TestTools.UnitTesting;
4+
5+
namespace DemaConsulting.DotnetToolWrapper.Tests;
6+
7+
/// <summary>
8+
/// Integration tests for the DotnetToolWrapper application
9+
/// </summary>
10+
[TestClass]
11+
public class IntegrationTests
12+
{
13+
/// <summary>
14+
/// Test setup directory
15+
/// </summary>
16+
private string _testDirectory = string.Empty;
17+
18+
/// <summary>
19+
/// Initialize test
20+
/// </summary>
21+
[TestInitialize]
22+
public void TestInitialize()
23+
{
24+
// Create a unique test directory
25+
_testDirectory = Path.Combine(Path.GetTempPath(), $"DotnetToolWrapperTests_{Guid.NewGuid()}");
26+
Directory.CreateDirectory(_testDirectory);
27+
}
28+
29+
/// <summary>
30+
/// Cleanup test
31+
/// </summary>
32+
[TestCleanup]
33+
public void TestCleanup()
34+
{
35+
// Clean up test directory
36+
if (Directory.Exists(_testDirectory))
37+
{
38+
Directory.Delete(_testDirectory, true);
39+
}
40+
41+
// Clean up config file next to DLL
42+
var dllPath = GetDotnetToolWrapperDllPath();
43+
var dllDirectory = Path.GetDirectoryName(dllPath);
44+
if (dllDirectory != null)
45+
{
46+
var configPath = Path.Combine(dllDirectory, "DotnetToolWrapper.json");
47+
if (File.Exists(configPath))
48+
{
49+
File.Delete(configPath);
50+
}
51+
}
52+
}
53+
54+
/// <summary>
55+
/// Get the path to the DotnetToolWrapper DLL
56+
/// </summary>
57+
/// <returns>Path to DLL</returns>
58+
private static string GetDotnetToolWrapperDllPath()
59+
{
60+
var assemblyLocation = Path.GetDirectoryName(typeof(IntegrationTests).Assembly.Location);
61+
Assert.IsNotNull(assemblyLocation, "Assembly location should not be null");
62+
return Path.Combine(assemblyLocation, "DemaConsulting.DotnetToolWrapper.dll");
63+
}
64+
65+
/// <summary>
66+
/// Create a DotnetToolWrapper.json configuration file
67+
/// </summary>
68+
/// <param name="program">Program to execute</param>
69+
/// <param name="directory">Directory for config file (defaults to DLL directory)</param>
70+
private static void CreateConfigFile(string program, string? directory = null)
71+
{
72+
// Default to DLL directory
73+
if (directory == null)
74+
{
75+
var dllPath = GetDotnetToolWrapperDllPath();
76+
directory = Path.GetDirectoryName(dllPath);
77+
Assert.IsNotNull(directory, "DLL directory should not be null");
78+
}
79+
80+
var target = Program.GetTarget();
81+
82+
var json = JsonSerializer.Serialize(new Dictionary<string, object>
83+
{
84+
{ target, new { program } }
85+
});
86+
87+
File.WriteAllText(Path.Combine(directory, "DotnetToolWrapper.json"), json);
88+
}
89+
90+
/// <summary>
91+
/// Get the shell program name for the current OS
92+
/// </summary>
93+
/// <returns>Shell program name</returns>
94+
private static string GetShellProgram()
95+
{
96+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
97+
{
98+
// Use COMSPEC environment variable to get full path to cmd.exe
99+
var comspec = Environment.GetEnvironmentVariable("COMSPEC");
100+
return comspec ?? throw new InvalidOperationException("COMSPEC environment variable not found");
101+
}
102+
103+
return "/bin/sh";
104+
}
105+
106+
/// <summary>
107+
/// Get shell arguments for exit code test
108+
/// </summary>
109+
/// <param name="exitCode">Exit code to test</param>
110+
/// <returns>Shell arguments</returns>
111+
private static string[] GetExitCodeArgs(int exitCode)
112+
{
113+
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
114+
? ["/c", $"exit {exitCode}"]
115+
: ["-c", $"exit {exitCode}"];
116+
}
117+
118+
/// <summary>
119+
/// Get shell arguments for echo test
120+
/// </summary>
121+
/// <param name="text">Text to echo</param>
122+
/// <returns>Shell arguments</returns>
123+
private static string[] GetEchoArgs(string text)
124+
{
125+
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
126+
? ["/c", $"echo {text}"]
127+
: ["-c", $"echo {text}"];
128+
}
129+
130+
/// <summary>
131+
/// Test that missing configuration file results in expected error
132+
/// </summary>
133+
[TestMethod]
134+
public void TestMissingConfigFile()
135+
{
136+
// Arrange
137+
var dllPath = GetDotnetToolWrapperDllPath();
138+
139+
// Act
140+
var exitCode = Runner.Run(out var output, "dotnet", dllPath);
141+
142+
// Assert
143+
Assert.AreEqual(1, exitCode, "Exit code should be 1 for missing config file");
144+
Assert.IsTrue(output.Contains("Missing configuration file"), "Output should mention missing config file");
145+
Assert.IsTrue(output.Contains("DotnetToolWrapper.json"), "Output should mention config file name");
146+
}
147+
148+
/// <summary>
149+
/// Test that exit codes are properly passed through
150+
/// </summary>
151+
[TestMethod]
152+
[DataRow(0)]
153+
[DataRow(1)]
154+
[DataRow(42)]
155+
[DataRow(255)]
156+
public void TestExitCodes(int expectedExitCode)
157+
{
158+
// Arrange
159+
var dllPath = GetDotnetToolWrapperDllPath();
160+
var shellProgram = GetShellProgram();
161+
162+
// Create config file pointing to shell
163+
CreateConfigFile(shellProgram);
164+
165+
// Get shell arguments to exit with specific code
166+
var shellArgs = GetExitCodeArgs(expectedExitCode);
167+
168+
// Prepare arguments for dotnet command
169+
var args = new List<string> { dllPath };
170+
args.AddRange(shellArgs);
171+
172+
// Act
173+
var exitCode = Runner.Run(out _, "dotnet", args.ToArray());
174+
175+
// Assert
176+
Assert.AreEqual(expectedExitCode, exitCode, $"Exit code should be {expectedExitCode}");
177+
}
178+
179+
/// <summary>
180+
/// Test that arguments are properly passed through
181+
/// </summary>
182+
[TestMethod]
183+
public void TestArgumentPassing()
184+
{
185+
// Arrange
186+
var dllPath = GetDotnetToolWrapperDllPath();
187+
var shellProgram = GetShellProgram();
188+
var testText = "HelloWorld";
189+
190+
// Create config file pointing to shell
191+
CreateConfigFile(shellProgram);
192+
193+
// Get shell arguments to echo text
194+
var shellArgs = GetEchoArgs(testText);
195+
196+
// Prepare arguments for dotnet command
197+
var args = new List<string> { dllPath };
198+
args.AddRange(shellArgs);
199+
200+
// Act
201+
var exitCode = Runner.Run(out var output, "dotnet", args.ToArray());
202+
203+
// Assert
204+
Assert.AreEqual(0, exitCode, "Exit code should be 0");
205+
Assert.IsTrue(output.Contains(testText), $"Output should contain '{testText}'");
206+
}
207+
208+
/// <summary>
209+
/// Test that unsupported target results in expected error
210+
/// </summary>
211+
[TestMethod]
212+
public void TestUnsupportedTarget()
213+
{
214+
// Arrange
215+
var dllPath = GetDotnetToolWrapperDllPath();
216+
var dllDirectory = Path.GetDirectoryName(dllPath);
217+
Assert.IsNotNull(dllDirectory, "DLL directory should not be null");
218+
219+
// Create config file with fake target
220+
var json = JsonSerializer.Serialize(new Dictionary<string, object>
221+
{
222+
{ "fake-target", new { program = "fake" } }
223+
});
224+
File.WriteAllText(Path.Combine(dllDirectory, "DotnetToolWrapper.json"), json);
225+
226+
// Act
227+
var exitCode = Runner.Run(out var output, "dotnet", dllPath);
228+
229+
// Assert
230+
Assert.AreEqual(1, exitCode, "Exit code should be 1 for unsupported target");
231+
Assert.IsTrue(output.Contains("does not support"), "Output should mention unsupported target");
232+
}
233+
234+
/// <summary>
235+
/// Test that bad configuration results in expected error
236+
/// </summary>
237+
[TestMethod]
238+
public void TestBadConfiguration()
239+
{
240+
// Arrange
241+
var dllPath = GetDotnetToolWrapperDllPath();
242+
var dllDirectory = Path.GetDirectoryName(dllPath);
243+
Assert.IsNotNull(dllDirectory, "DLL directory should not be null");
244+
var target = Program.GetTarget();
245+
246+
// Create config file without program property
247+
var json = JsonSerializer.Serialize(new Dictionary<string, object>
248+
{
249+
{ target, new { notprogram = "fake" } }
250+
});
251+
File.WriteAllText(Path.Combine(dllDirectory, "DotnetToolWrapper.json"), json);
252+
253+
// Act
254+
var exitCode = Runner.Run(out var output, "dotnet", dllPath);
255+
256+
// Assert
257+
Assert.AreEqual(1, exitCode, "Exit code should be 1 for bad configuration");
258+
Assert.IsTrue(output.Contains("Bad configuration"), "Output should mention bad configuration");
259+
}
260+
}

0 commit comments

Comments
 (0)