Unit Testing SLX
Nicolas Galler | December 2, 2007While the framework provided by Sage makes it quite convenient to develop the web application, there is little to no documentation on what is needed to run it “stand-alone” as is needed for unit-testing. This post describes a scenario for a simple unit test I want to run against one of the business rules I designed for my entity. In addition to being useful in its own right for writing unit tests, it helped me understand a lot of the inner workings of the framework.
The specification for the business rule is quite simple: if a Salesorder entity associated with an Opportunity has its status changed to Won, the Opportunity should be changed to Won, and all other sales orders on the opportunity need to have their status changed to “Else Won”. Here is the code for the unit test in its unmodified form:
IOpportunity opportunity = EntityFactory.Create<IOpportunity>(); ISalesorder quote = new SalesOrderBuilder().Build(opportunity); ISalesorder secondQuote = new SalesOrderBuilder().Build(opportunity); try { opportunity.Save(); quote.Status = "Won"; quote.SaveForm(); Assert.AreEqual(quote.Status, "Won", "Quote status should save as Won"); Assert.AreEqual(opportunity.Status, "Closed - Won", "Opportunity status should change to Closed - Won"); Assert.AreEqual(secondQuote.Status, "Else Won", "Other Quote status should save as Else Won"); } finally { try { opportunity.Delete(); } catch (Exception x) { LOG.Warn("Error cleaning up opportunity", x); } }
If we try running the above code it will crash as soon as we attempt to access the EntityFactory, for it requires the whole ApplicationContext to be instantiated and configured. With much trial and error I found out the following:
- ApplicationContext.Initialize(appId) will initialize the application. appId is an arbitrary string. This returns a WorkItem object, which we can then use through the rest of the tests. It also initializes ApplicationContext.Current, which is good because some methods are hard-wired to use that global instead of the parameters (grrr). In the web client this is done by the AppManagerModule, a registered HttpModule.
- Each of the pieces of the app that need a configuration file (for example NHibernate and the Dynamic Method invocation piece) will by default read it under All Users\Application Data\…….Configuration\Application\<appId>\… Of course, that doesn’t work for testing, and it can be redirected, but for each module that you want reading its configuration from another spot you will have to write something like this (for NHibernate, in this case):
ConfigurationManager configManager = workItem.Services.Get
(); ReflectionConfigurationTypeInfo typeInfo = new ReflectionConfigurationTypeInfo(typeof(HibernateConfiguration)); typeInfo.ConfigurationSourceType = typeof(FileConfigurationSource); configManager.RegisterConfigurationType(typeInfo); This will cause the code to read the file from Configuration\Application\<appId>\, relative to the current directory. For the web client they have a slightly different strategy. The code that reads the configuration detects when it is run on the web (HttpContext.Current != null) and in that case uses Server.MapPath to resolve the configuration path.
- At that point you will have to add a bunch of the Saleslogix assemblies to the references for the app. I was half tempted to add the entire web bin folder but I just stuck it out and re-ran the application until I got all assemblies. In addition to the regular list you will need the Sage.Entity.Interfaces and Sage.SalesLogix.Entities assemblies that are specific to the project, and the following SLX assemblies:
- Castle.DynamicProxy.dll
- NHibernate.Caches.SysCache.dll
- Sage.SalesLogix.Activity.dll
- Sage.SalesLogix.BusinessRules.dll
- Sage.SalesLogix.NHibernate.dll
- Sage.SalesLogix.Plugins.dll
- Sage.SalesLogix.SpeedSearch.dll
- Saving or querying NHibernate will not work until a “DataService” which can open new connections is defined. I created a “TestDataService” class which reads the connection string defined in app config and serves it. Then, it needs to be registered like so:
workItem.Services.AddNew(typeof(TestDataService), typeof(IDataService));
- Some other services may be required by the code inside of the business rules. The following should probably be added:
workItem.Services.AddNew(typeof(MockUserService), typeof(IUserService));
workItem.Services.AddNew(typeof(WebUserOptionsService), typeof(IUserOptionsService));
The MockUserService is defined to hard-code Admin as Userid/Username (note that the username is really the user code, not the user name). The default implementation returns the Windows user name instead. Not sure what the reasoning was there, but anyway, it won’t work. - To simulate a databound context we need to emulate what the page does (look at Account.aspx for example) and set up the entity context (which also registers the entity history service if it hasn’t already been done):
_workItem.Services.AddNew(typeof(EntityFactoryContextService), typeof(IEntityContextService));
- Problem in NHibernate: connection is closed… Happens in SessionImpl, Cascades.Cascade (inside of DoSave). You can use this.connectionManager.connection.State to debug. I have not yet found the solution to this one, but it only happens when saving so it is not critical for testing. My gut feeling is this happens inside of the ID generator.
Here is my complete (current) TestSetup class, feel free to rip, you may have to adjust a few of the SSSWorld.Common dependencies. I will come back and update this post as needed if I find new things:
using System; using System.Collections.Generic; using System.Text; using NUnit.Framework; using Sage.Platform.Application; using Sage.Platform.Configuration; using Sage.Platform.Data; using System.Text.RegularExpressions; using SSSWorld.Common; using System.IO; using log4net; using System.Data.OleDb; using System.Data; using Sage.Platform.DynamicMethod; using Sage.SalesLogix.Security; using Sage.SalesLogix.Web; using Sage.SalesLogix; using Sage.Platform.Security; using System.Threading; using System.Security.Principal; using System.Diagnostics; using Sage.Platform.Services; namespace SSSWorld.Cablofil.SlxPlatform.BL.UnitTest { [SetUpFixture] public class TestSetup { private WorkItem _workItem = null; private static readonly ILog LOG = LogManager.GetLogger(typeof(TestSetup)); [SetUp] public void SetupTest() { log4net.Config.XmlConfigurator.Configure(); //Globals.Initialize(); try { _workItem = ApplicationContext.Initialize("Test"); _workItem.Services.AddNew(typeof(TestDataService), typeof(IDataService)); ConfigurationManager configManager = _workItem.Services.Get<ConfigurationManager>(); ReflectionConfigurationTypeInfo typeInfo = new ReflectionConfigurationTypeInfo(typeof(HibernateConfiguration)); typeInfo.ConfigurationSourceType = typeof(FileConfigurationSource); configManager.RegisterConfigurationType(typeInfo); typeInfo = new ReflectionConfigurationTypeInfo(typeof(DynamicMethodConfiguration)); typeInfo.ConfigurationSourceType = typeof(FileConfigurationSource); configManager.RegisterConfigurationType(typeInfo); //Thread.CurrentPrincipal = new SLXWindowsPrincipal(User.GetById("ADMIN"), WindowsIdentity.GetCurrent()); _workItem.Services.AddNew(typeof(MockUserService), typeof(IUserService)); _workItem.Services.AddNew(typeof(WebUserOptionsService), typeof(IUserOptionsService)); _workItem.Services.AddNew(typeof(EntityFactoryContextService), typeof(IEntityContextService)); } catch (Exception x) { LOG.Warn("Test setup failed", x); throw; } } [TearDown] public void TearDown() { if (_workItem != null) { try { _workItem.Dispose(); } catch (Exception x) { LOG.Warn("Error trying to dispose work item", x); } try { ApplicationContext.Shutdown(); } catch (Exception x) { LOG.Warn("Error shutting down app context", x); } } } public class MockUserService : SLXUserService { #region IUserService Members public override string UserId { get { return "ADMIN"; } } public override string UserName { get { return "Admin"; } } #endregion } /// <summary> /// This data service accesses the connection string named "Saleslogix" defined in app.config. /// Username and password must be specified in the connection string. /// </summary> public class TestDataService : IDataService { private OleDbConnection _connection; #region IDataService Members public string Database { get { Regex rx = new Regex("Initial Catalog= *([^ ;])"); Match m = rx.Match(GetConnectionString()); if (m.Success) return m.Groups[1].Value; throw new InvalidOperationException("Could not extract database name from connection string"); } } public string Server { get { Regex rx = new Regex("Data Source= *([^ ;])"); Match m = rx.Match(GetConnectionString()); if (m.Success) return m.Groups[1].Value; throw new InvalidOperationException("Could not extract server from connection string"); } } public System.Data.IDbConnection GetConnection() { lock (this) { if (this._connection == null) { this._connection = new OleDbConnection(this.GetConnectionString()); } else if (this._connection.ConnectionString.CompareTo(this.GetConnectionString()) != 0) { if (this._connection.State == ConnectionState.Open) { this._connection.Close(); } this._connection.Dispose(); this._connection = new OleDbConnection(this.GetConnectionString()); } } return this._connection; } public string GetConnectionString() { return MyConfiguration.Instance.GetConnectionString("Saleslogix"); } #endregion } } }
This should be useful also for running external applications accessing the Saleslogix database, for example Windows services. It is not practical yet because of the lack of transaction support but should be in the near future (I plan to come back to this and fix the current issue with saving an entity at that time!)






[...] follow up to my post, Unit Testing SLX. The post was written on Saleslogix 7.2.1 and there have been several very, very good improvements [...]