Intended Audience #
This is written for the AutoCAD or Civil 3D plugin developers, who, in addition to want to write the plugin, are also interested to write unit tests on it.
It also presupposes the familiarity with a popular unit testing framework– the NUnit testing framework.
NUnit 2.6, not Nunit 3.x ( or higher) #
We are using NUnit 2.6, the latest version in the NUnit 2.x series. In NUnit 3.x above, there is a huge architecture change in NUnit 3.x that makes it unworkable with AutoCAD (or any drawing based software). In NUnit 3.x, all the tests are run in child threads and not main threads, as shown below:
However, any AutoCAD or Civil 3D code that runs, must always run in the main threads. There is no way to force NUnit 3.x to run in main threads, hence we have to use NUnit 2.6.
You may want to download the NUnit 2.6 from the github repository and compile it on your own in order to use the DLLs. Take note that the github version is a .Net framework 4.8
one, so if you are using Civil 3D 2025 ( or >.Net 8.0
), you will have to modify the existing code to make it compatible with .Net 8.0
. This should be easy as my experience shows that you are only using a subset of the NUnit 2.6 code, and they are all compatible with .Net 8.0
.
Main assemblies setup #
Your unit tests can only run in accoreconsole.exe
and not the acad.exe
, so when you are running your plugin code with unit testing framework in accoreconsole.exe
, your plugin must not have any references to Autodesk UI dlls like acmgd
, AcWindows
, AdWindows
because these dlls will cause the accoreconsole.exe
to crash. Your plugin code that are subjected unit testing can only refer to non-UI dlls such as accoremgd
, acdbmgd
, AecBaseMgd
, AecDbMgd
.
So for main assembly setup, it’s wise to separate your main production plugin code into two project, one project that contains reference to only Autodesk non-UI dlls, the code that you will unit test, and another project that can refer to Autodesk UI dlls. The second project is not unit testable. Your plugin client distribution must contain 2 dlls at least.
Test assemblies setup #
For test assemblies setup, you have to divide the test code into two projects, the “core” project/assembly that hosts all the test methods ( ie: the classes that are decorated with TestFixture
attribute, and the methods that are decorated with the Test
attribute), and the “runner” assembly that calls the “core” assembly via RemoteTestRunner
class.
A useful point of reference is the code from RvtUnit project which constitutes the main concept in my test setup. You may also refer to the RvtUnit project itself as you read through the below code.
I have two CommandMethod
as the entry points for the tests, which locates in the runner assembly. One CommandMethod
is for the purpose of running the full test suite from the console line, I run it whenever I do a full build. Another is for the purpose of testing, I only run selected tests and I need to use a dialog box to select the tests I want to run.
// This runs the full test suites during nightly build
[CommandMethod(nameof(RunCADTestsFull), CommandFlags.Session)]
public void RunCADTestsFull()
{
var dllLocation = typeof(BaseTests).Assembly.Location;
TestPackage theTestPackage = new TestPackage("All Dlls", new List<string>() { dllLocation });
theTestPackage.Settings.Add("UseThreadedRunner", false);
theTestPackage.Settings.Add("DomainUsage", DomainUsage.Single);
var testRunner = new RemoteTestRunner();
testRunner.Load(theTestPackage);
TestResult testResult = testRunner.Run(NullListener.NULL, TestFilter.Empty, false, LoggingThreshold.Off);
}
/// <summary> This command runs the selected NUnit tests </summary>
[CommandMethod(nameof(RunCADtestsIndividual), CommandFlags.Session)]
public void RunCADtestsIndividual()
{
var selectDialogVM = new SelectDialogViewModel();
var ui = new SelectDialogUI(selectDialogVM);
ui.ShowDialog();
}
Take note that the BaseTests
is located in the core assembly, separated from where the above CommandMethod
s are located.
Another important repository that I refer to is CADbloke’s CADtest. Specifically I refer to how it constructs the IExtensionApplication
, and how one netload
s the running assembly into AutoCAD process. This is how I construct my IExtensionApplication
class, again, it’s located in the runner assembly.
/// <summary> AutoCAD extension application implementation. </summary>
/// <seealso cref="T:Autodesk.AutoCAD.Runtime.IExtensionApplication" />
public class NUnitRunnerApp : IExtensionApplication
{
public void Initialize()
{
if (!AttachConsole(-1)) // Attach to a parent process console
AllocConsole(); // Alloc a new console if none available
return;
#if !CoreConsole
if ( !AttachConsole(-1) ) // Attach to a parent process console
AllocConsole(); // Alloc a new console if none available
#else
// http://forums.autodesk.com/t5/net/accoreconsole-exe-in-2015-doesn-t-do-system-console-writeline/m-p/5551652
// This code is so you can see the test results in the console window, as per the above forum thread.
if (Application.Version.Major * 10 + Application.Version.Minor > 190) // >v2013
{
// http://stackoverflow.com/a/15960495/492
// stdout's handle seems to always be equal to 7
var defaultStdout = new IntPtr(7);
IntPtr currentStdout = GetStdHandle(StdOutputHandle);
if (currentStdout != defaultStdout)
SetStdHandle(StdOutputHandle, defaultStdout);
// http://forums.autodesk.com/t5/net/accoreconsole-exe-in-2015-doesn-t-do-system-console-writeline/m-p/5551652#M43708
FixStdOut();
}
#endif
}
public void Terminate()
{
#if !CoreConsole
FreeConsole();
#endif
}
// http://stackoverflow.com/questions/3917202/how-do-i-include-a-console-in-winforms/3917353#3917353
/// <summary> Allocates a new console for current process. </summary>
[DllImport("kernel32.dll")]
public static extern bool AllocConsole();
/// <summary> Frees the console. </summary>
[DllImport("kernel32.dll")]
public static extern bool FreeConsole();
// http://stackoverflow.com/a/8048269/492
[DllImport("kernel32.dll")]
private static extern bool AttachConsole(int pid);
#if CoreConsole
// http://forums.autodesk.com/t5/net/accoreconsole-exe-in-2015-doesn-t-do-system-console-writeline/m-p/5551652#M43708
// trying to fix the telex-like line output from Core Console in versions > 2013. This didn't work for me. :(
[DllImport("msvcr110.dll")]
private static extern int _setmode(int fileno, int mode);
public void FixStdOut() { _setmode(3, 0x4000); }
// http://stackoverflow.com/a/15960495/492
private const uint StdOutputHandle = 0xFFFFFFF5;
[DllImport("kernel32.dll")]
private static extern IntPtr GetStdHandle(uint nStdHandle);
[DllImport("kernel32.dll")]
private static extern void SetStdHandle(uint nStdHandle, IntPtr handle);
#endif
}
The CADtest contains valuable .Net code on how to sideload the database into the AutoCAD document.
Taken all in all, the above classes are what constitutes my runner assembly. The core assembly is consisting of just all the TestFixture
classes, so I would omit them for brevity sake.
The scr
and batch script #
This is how I construct my scr
script in order to call my runner assembly for unit testing purpose. I have to call accoreconsole.exe
and pass in the scr
file for the RunCADTestsFull
method because it can only run in command line.
# the content of NetLoadScript.scr
netload "..\RunnerAssembly.dll"
RunCADTestsFull
# the command line
%ACADDir%accoreconsole.exe %ACADDir%AecBase.dbx" /p "<<C3D_Metric>>" /product "C3D" /b "..\NetLoadScript.scr"
Future work #
Even though you can still set break points and break into the test methods, there is no way to run the tests in Visual Studio Test Explorer ( ie:, no way to run it via dotnet test
). Hopefully this will change in the future, just like Ricaun’s excellent Revit Test; see also the discussion here.
That’s pretty much it, do let me know in the comments if you have further clarifications. I’ll be glad to reply.