TCR C#
test && commit || revert
With minmal looking, I haven't found a way to do it effectively for C#. So, I'm going to create my own.
Kent Beck kicked off this idea. I'm not sure if it's this post that first published it, but it's where I'm linking to.
A couple reference posts I'm using are here and here. They seem to have some solidly thought out functionality for TCR.
The big thing I'm using from these posts is a block of psuedo-code
if(build().failed)
return
if(test().success)
commit()
else
revert()
Let's TCR!
Since this is all gonna be executed from a script (oooo.... dreams of a plugin...) I'll be working with msbuild.
I have a tenant at Visualstudio.com and a local build agent. These give some great logging around how to build from the comamnd line... and this project is using dotnet core... That throws a ... the project I started is dotnet core as well...
OK, no msbuild. Let's look to the future!
I'll probably be using the dotnet core cli documentation pretty heavily. Sounds like a plan.
My project and solution are called TCR... so... Let's build the solution.
I'm in my solution directory and here's the command line
PS E:\src\github\fyzxs\TCR> dotnet build TCR.sln
Microsoft (R) Build Engine version 16.3.0+0f4c62fea for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.
Restore completed in 41.72 ms for E:\src\github\fyzxs\TCR\TCR\TCR.csproj.
TCR -> E:\src\github\fyzxs\TCR\TCR\bin\Debug\netcoreapp3.0\TCR.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:05.13
PS E:\src\github\fyzxs\TCR>
Seems line the idea of a build().success
works... but we need a failure...
Modifying source to not compile.. simple
PS E:\src\github\fyzxs\TCR> dotnet build TCR.sln
Microsoft (R) Build Engine version 16.3.0+0f4c62fea for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.
Restore completed in 23.68 ms for E:\src\github\fyzxs\TCR\TCR\TCR.csproj.
UnitTest1.cs(11,18): error CS1002: ; expected [E:\src\github\fyzxs\TCR\TCR\TCR.csproj]
Build FAILED.
UnitTest1.cs(11,18): error CS1002: ; expected [E:\src\github\fyzxs\TCR\TCR\TCR.csproj]
0 Warning(s)
1 Error(s)
Time Elapsed 00:00:00.80
PS E:\src\github\fyzxs\TCR>
Powershell has a built in command to pull the exit code from an app - $LASTEXITCODE
.
I'll use that.
Need to create a nice little function...
You're a bit lucky reader, you didn't have to watch me google the very fundamentals of powershell. And EVERYTHING being things I've done before, pretty indepth too.
Function Build-Failed{
Invoke-Expression -Command:"dotnet build TCR.sln"
return $LASTEXITCODE -eq 1
}
Yes, it's a hack. All hard coded just like that. Very much so - But it works for the exploration I'm currently doing. It's like TDD. Simplest possible. :)
Next up (no, I'm not refactoring yet) is to run the tests.
Why look, dotnet has a command dotnet test
.
Which provides the following
PS E:\src\github\fyzxs\TCR> dotnet test TCR.sln
Test run for E:\src\github\fyzxs\TCR\TCR\bin\Debug\netcoreapp3.0\TCR.dll(.NETCoreApp,Version=v3.0)
Microsoft (R) Test Execution Command Line Tool Version 16.3.0
Copyright (c) Microsoft Corporation. All rights reserved.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Test Run Successful.
Total tests: 1
Passed: 1
Total time: 1.2841 Seconds
The $LASTEXITCODE
for that is 0
. Time to fail!!!
PS E:\src\github\fyzxs\TCR> dotnet test TCR.sln
Test run for E:\src\github\fyzxs\TCR\TCR\bin\Debug\netcoreapp3.0\TCR.dll(.NETCoreApp,Version=v3.0)
Microsoft (R) Test Execution Command Line Tool Version 16.3.0
Copyright (c) Microsoft Corporation. All rights reserved.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
X TestMethod1 [23ms]
Error Message:
Assert.Fail failed.
Stack Trace:
at TCR.UnitTest1.TestMethod1() in E:\src\github\fyzxs\TCR\TCR\UnitTest1.cs:line 11
Test Run Failed.
Total tests: 1
Failed: 1
Total time: 0.7437 Seconds
As I was hoping, the $LASTEXITCODE
is indeed 1
. Time to C&P a method...
Function Tests-Pass{
Invoke-Expression -Command:"dotnet test TCR.sln"
return $LASTEXITCODE -eq 0
}
When our tests pass, we commit
. Simple enough command.
...
Except I don't wanna commit first.
I'm going to do the else block and reset.
Function Git-Reset-Hard{
Invoke-Command "git reset --hard"
}
I added a helper function for some duplication I'm getting
Function Invoke-Command($command){
Invoke-Expression -Command:$command | Write-Host
}
That'll run the command an give the output.
The script part of the powershell looks like
if(Build-Failed){
Write-Host "### Build failed. No change."
return
}
Write-Host "### Build Passed"
if(Tests-Pass){
Write-Host "### Tests Passed. Commiting Changes."
# Eventually commit
} else {
Write-Host "### Tests Failed. Reverting..."
Git-Reset-Hard
}
We can see that it's following the psuedo code we have above.
Code gets reverted! Sweet.
Let's commit
Function Git-Commit-All{
Invoke-Command "git add -A"
Invoke-Command "git commit -m 'tests Pass'"
}
My test now asserts that true is true.
Wooo!
### Tests Passed. Commiting Changes.
[master 2492c8d] tests Pass
1 file changed, 1 insertion(+)
And it's committed.
if I update the test to assert true is false... it should revert.
### Tests Failed. Reverting...
HEAD is now at 2492c8d tests Pass
and so it does.
Uh Oh
... It's gonna be a little hard to do TDD with TCR when a failing test causes the code to revert.
In the guiding articles listed about, they use Java, which has a code organization practice of using a src
and test folder.
I've created these folders and moved the test project inside the test folder.
Gonna add a ClassLibrary.
Discipline
Everything that I've seen that makes a developer excel at the profession is around discipline. If you're going to write code w/o tests; then TCR can't help you.
It takes discipline to do TDD and even more to have TCR running.
I'll assume you're going to be doing TDD and we'll have some fun.
Make it fail!
I'm going to write a failing test that will test something in my class library.
Using "Programming by Intent" I write the method before it exists... What does my script do...
PS E:\src\github\fyzxs\TCR> ..\tcr.ps1
### Building the solution
### Build failed. No change.
Nothing, perfect.
What happens when I have my method return the wrong value...
I expect it'll nuke the method...
...Oops... I forgot to commit after mucking about with folder structure... Let's fix and commit that.
OK, back to it. Now it's returning the wrong value... bah... and commited... OK, will fix it and then try it again.
Test with non-existant doesn't build, we're good.
By default the method throws an exception... and it deleted the method...
and the test...
OK, back to the reason for the folder structure... we can checkout the src
and not have our tests affected by the reset.
Modifying our reset command to be
Function Git-Reset-Hard{
Invoke-Command "git checkout HEAD -- src"
}
only things in the src folder get reverted.
But ... how do we go RED if it gets nuked...
...
NotImplemented - OK
Playing around a little I realized it's just because the default in C# is to generate the methods with a "NotImplementedException". That... That needs to be allowed.
OK, I've tweaked the script to allow
Function Build-Failed{
Write-Host "### Building the solution"
Invoke-Command "dotnet build TCR.sln"
return $LASTEXITCODE -eq 1
}
function Tests-Run{
Write-Host "### Running tests"
Invoke-Expression -Command: "dotnet test TCR.sln" | Tee-Object -Variable output | Write-Host
return $output
}
Function Tests-Pass($testOutput){
return $testOutput -Match "Test Run Successful"
}
Function Single-NotImplementedException($testOutput){
$count = ([regex]::Matches($testOutput, "System.NotImplementedException: The method or operation is not implemented." )).count
return $count -eq 1
}
Function Commit{
Invoke-Command "git add --all"
Invoke-Command "git commit -m 'tests pass'"
}
Function Revert{
# Invoke-Command
# Invoke-Command "git checkout HEAD -- src"
}
Function Invoke-Command($command){
Invoke-Expression -Command: $command | Write-Host
}
if(Build-Failed){
Write-Host "### Build failed. No change."
return
}
Write-Host "### Build Passed"
$testOutput = Tests-Run
if(Single-NotImplementedException $testOutput){
Write-Host "### A single NotImplementedException is allowed. No Change."
return
}
if(Tests-Pass $testOutput){
Write-Host "### Tests Passed. Commiting Changes."
Commit
} else {
Write-Host "### Tests Failed. Reverting..."
Revert
}
Now the script allows a single occurance of a NotImplementedException error.
We can't have a failing test for the 'right' reason. If we remove the throw and put in a value to make the test fail... it will be reverted.
I'm inclined to allow a single failing test... No... That allows writing WAY too much code. OK, that idea is terrible. Not happening.
Limbo?
This all started with a Limbo on the cheap post by Kent. In which the script does a git pull --rebase`` and
git push` when the tests pass.
The reference posts also get into this... I like it.
First though, I want the script to run in a loop. I don't want to be tasked with remembering to run the tests.
There was a bit of head bashing and script tweaking, but it's running in a loop.
Does a commit with a message. Happy there.
It gets... stuck? sometimes and the script needs to be killed/restarted.
Rebase and push
Getting the rebase/push in was easy. Change this method
Function Commit{
Invoke-Command "git add --all"
Invoke-Command "git commit -m '$commitMsg'"
}
to
Function Commit{
Invoke-Command "git add --all"
Invoke-Command "git commit -m '$commitMsg'"
Invoke-Command "git pull --rebase"
Invoke-Command "git push"
}
and DONE!
I was worried about issues if there's no remote... nope. It just does nothing. I don't have to be fancy and check for upstream or anything.
Simplest possible is the best way to start. Eliminates what's not needed.
Done
I'm pretty happy with the little script.
It's on GitHub. Happy to hear feedback on it.