Click here to Skip to main content
Click here to Skip to main content

An NUnit Test Suite Implementation

, 26 Aug 2006
Rate this:
Please Sign up or sign in to vote.
This article describes a scalable NUnit unit test suite for use on a tiered, database-driven .NET application.

Introduction

In this article, I will describe a scalable NUnit unit test suite for use on a tiered, database-driven .NET application. The suite will define sample generators used to easily create dummy data for tests, and it will use test fixture inheritance for increased scalability and to allow for easy testing of common functionality.

I will focus on testing of the domain model of a sample application. This is usually located in the "middle" of an application and is often called the business logic layer (BLL). It uses the data access layer (DAL) to mediate data transfer to and from the database and drives the behavior of one or more user interfaces (UI) with which the user interacts or in which data is displayed.

This article assumes knowledge of .NET and C#, but does not require experience with unit testing or NUnit in particular.

BLL Implementation

In this sample application, classes in the BLL that implement database operations inherit from a base class called PersistentObject. This class defines the following interface[1]:

public abstract class PersistentObject {
    protected long _uid = long.MinValue;

    /// <span class="code-SummaryComment"><summary>
</span>
    /// The unique identifier of this object 
    ///  in the database
    /// <span class="code-SummaryComment"></summary>
</span>
    /// <span class="code-SummaryComment"><remarks>
</span>
    /// Set when Fill() is called
    /// <span class="code-SummaryComment"></remarks>
</span>
    public long UID {
        get { return _uid; }
    }

    /// <span class="code-SummaryComment"><summary>
</span>
    /// Save this object's data to the database. 
    /// <span class="code-SummaryComment"></summary>
</span>
    public abstract void Save();

    /// <span class="code-SummaryComment"><summary>
</span>
    /// Fill this object with data fetched from the
    ///  database for the given UID
    /// <span class="code-SummaryComment"></summary>
</span>
    /// <span class="code-SummaryComment"><param name="uid">The unique identifier of
</span>
    /// the record to fetch from the database<span class="code-SummaryComment"></param>
</span>
    public abstract void Fill(long uid);

    /// <span class="code-SummaryComment"><summary>
</span>
    /// Remove this object from the database
    /// <span class="code-SummaryComment"></summary>
</span>
    public abstract void Delete();

    /// <span class="code-SummaryComment"><summary>
</span>
    /// Fetches an object of the given type and with the
    ///  given UID from the database
    /// <span class="code-SummaryComment"></summary>
</span>
    /// <span class="code-SummaryComment"><typeparam name="ConcreteType">
</span>
    /// The type of object to fetch
    /// <span class="code-SummaryComment"></typeparam>
</span>
    /// <span class="code-SummaryComment"><param name="uid">
</span>
    /// The unique identifier of the object in the database
    /// <span class="code-SummaryComment"></param>
</span>
    public static ConcreteType Fetch<ConcreteType>(long uid) 
      where ConcreteType : PersistentObject, new() {
        ConcreteType toReturn = new ConcreteType();
        toReturn.Fill(uid);
        return toReturn;
    }
}

Say, for example, the application must save some client data and a client address that can be used elsewhere in the application. The BLL would therefore need to contain Address and Client classes derived from PersistentObject.

public class Address : PersistentObject {
    private string _streetAddress = null;
    private string _city = null;
    private string _state = null;
    private string _zip = null;

    public string StreetAddress {
        get { return _streetAddress; }
        set { _streetAddress = value; }
    }

    public string City {
        get { return _city; }
        set { _city = value; }
    }

    public string State {
        get { return _state; }
        set { _state = value; }
    }

    public string Zip {
        get { return _zip; }
        set { _zip = value; }
    }

    public override void Save() {
        // Call DAL to save fields
        // ...
    }
    public override void Fill(long uid) {
        // Call DAL to fill fields
        // ...
    }
    public override void Delete() {
        // Call DAL to delete object
        // ...
    }    

    /// <span class="code-SummaryComment"><summary>
</span>
    /// Utility function that returns the Address with 
    ///  the given UID
    /// <span class="code-SummaryComment"></summary>
</span>
    public static Address Fetch(long addressUID) {
        return PersistentObject.Fetch<Address>(addressUID);
    }    
}

Client is similar, except it contains a property that returns the Client's Address object.

public class Client : PersistentObject {
    private string _firstName = null;
    private string _lastName = null;
    private string _middleName = null;
    private long _addressUID = long.MinValue;

    private Address _addressObject;

    // ...

    public long AddressUID {
        get { return _addressUID; }
        set { _addressUID = value; }
    }

    /// <span class="code-SummaryComment"><summary>
</span>
    /// On-demand property that returns this Client's 
    ///  Address based on the current value of AddressUID
    /// <span class="code-SummaryComment"></summary>
</span>
    public Address Address {
        get {
            if (AddressUID == long.MinValue) {
                _addressObject = null;
            }
            else if (_addressObject == null 
              || AddressUID != _addressObject.UID) {
                _addressObject = new Address();
                _addressObject.Fill(AddressUID);
            }
            return _addressObject;
        }
    }

    // ...

}

To save new client data, the user would do something like the following:

// Create the address that the client will link to
Address newAddress = new Address();
newAddress.StreetAddress = StreetAddressInput.Text;
newAddress.City = CityInput.Text;
newAddress.State = StateInput.Text;
newAddress.Zip = ZipInput.Text;
// Save the address to the database
newAddress.Save();

// Create the client
Client newClient = new Client();
newClient.FirstName = FirstNameInput.Text;
newClient.MiddleName = MiddleNameInput.Text;
newClient.LastName = LastNameInput.Text;
// Link to the address
newClient.AddressUID = newAddress.UID;
// Save the client to the database
newClient.Save();

And to retrieve client data elsewhere in the application, the user would do something like the following:

Client existingClient = Client.Fetch(clientUID);
Address clientAddress = existingClient.Address;

Unit Testing Background

The BLL implementation outlined above is relatively standard. One can verify its behavior in any number of ways. The simplest but least robust is to test the UI. Since the UI depends on the BLL, one could conceivably verify the application by running through web pages or dialog boxes by hand. But what if the application has multiple UIs? Obviously, this method is slow, difficult to repeat, prone to human error, and may miss bugs. Also, it may promote bad programming practice in that a naïve coder may fix a symptom in the UI rather than the base cause in the BLL. This is not to say that we should omit UI testing, just that we should not rely on it to verify business logic.

A better option would be to create a simple driver program that calls the BLL method under development. This option would certainly be easier to repeat, but it may be difficult to save drivers for later or run all existing drivers to verify that nothing is broken.

This is where unit tests come in. One can think of a unit test as a simple driver program that one would probably write anyway. The unit testing framework organizes the tests, provides tools to make writing tests easier, and allows one to run tests in aggregate.

Test Suite Implementation

Since this article discusses a .NET application, I will use the NUnit testing framework in the example test suite. NUnit provides several features such as a test execution GUI, built-in assertions, and test attributes that make writing and running tests very easy.

It is most intuitive to create a test fixture (that is, a class containing a series of tests) for each class in the BLL. So, in keeping with the example, we will have ClientTest and AddressTest classes in the example test suite. These basic test fixtures will need to verify that data is added to the database, retrieved, edited, and deleted correctly. We often need to create dummy objects, so these test fixtures will also include some sample generators. Finally, we do not want to have to repeat common test code across many different test fixtures, so we will test the common database operations in a PersistentObjectTest class from which ClientTest and AddressTest both inherit.

I will explain the construction of PersistentObjectTest in parts. First, the class declaration:

/// <span class="code-SummaryComment"><summary>
</span>
/// Abstract base class for test fixtures that test 
///  classes derived from BLL.PersistentObject
/// <span class="code-SummaryComment"></summary>
</span>
/// <span class="code-SummaryComment"><typeparam name="PersistentObjectType">
</span>
/// The type of BLL.PersistentObject that the derived 
///  class tests
/// <span class="code-SummaryComment"></typeparam>
</span>
public abstract class PersistentObjectTest<PersistentObjectType> 
  where PersistentObjectType : PersistentObject, new() {

This shows that PersistentObjectTest is a generic type that accepts the type of the object that its derived class tests. This type derives from PersistentObject and has an empty constructor. This lets us create sample generators and other utilities in a type-safe, generic manner:

#region Sample Generators

/// <span class="code-SummaryComment"><summary>
</span>
/// Returns a dummy object
/// <span class="code-SummaryComment"></summary>
</span>
/// <span class="code-SummaryComment"><param name="type">
</span>
/// Indicates whether the returned dummy object should
///  be saved to the database or not
/// <span class="code-SummaryComment"></param>
</span>
public PersistentObjectType GetSample(SampleSaveStatus saveStatus) {
    PersistentObjectType toReturn = new PersistentObjectType();
    FillSample(toReturn);
    if (saveStatus == SampleSaveStatus.SAVED_SAMPLE) {
        toReturn.Save();
        // Check Save() postconditions...
    }
    return toReturn;
}

/// <span class="code-SummaryComment"><summary>
</span>
/// Fills the given object with random data
/// <span class="code-SummaryComment"></summary>
</span>
/// <span class="code-SummaryComment"><param name="sample">
</span>
/// The sample object whose fields to fill
/// <span class="code-SummaryComment"></param>
</span>
/// <span class="code-SummaryComment"><remarks>
</span>
/// Should be overridden and extended in 
///  derived classes
/// <span class="code-SummaryComment"></remarks>
</span>
public virtual void FillSample(PersistentObjectType sample) {
    // nothing to fill in the base class
}

/// <span class="code-SummaryComment"><summary>
</span>
/// Asserts that all fields in the given objects match
/// <span class="code-SummaryComment"></summary>
</span>
/// <span class="code-SummaryComment"><param name="expected">
</span>
///  The object whose data to check against
/// <span class="code-SummaryComment"></param>
</span>
/// <span class="code-SummaryComment"><param name="actual">
</span>
/// The object whose fields to test
/// <span class="code-SummaryComment"></param>
</span>
/// <span class="code-SummaryComment"><remarks>
</span>
/// Should be overridden and extended in 
///  derived classes
/// <span class="code-SummaryComment"></remarks>
</span>
public virtual void AssertIdentical
 (PersistentObjectType expected, PersistentObjectType actual) {
    Assert.AreEqual(expected.UID, actual.UID, 
      "UID does not match");
}

#endregion

GetSample() simply returns a dummy object. The implementations of FillSample() and AssertIdentical() are delegated to the derived classes. These three methods are used by other test fixtures to create and test sample objects. The base class uses them to verify the basic database operations in the following test methods:

#region Data Tests

/// <span class="code-SummaryComment"><summary>
</span>
/// Tests that data is sent to and retrieved from 
///  the database correctly
/// <span class="code-SummaryComment"></summary>
</span>
[Test()]
public virtual void SaveAndFetch() {
    PersistentObjectType original = 
      GetSample(SampleSaveStatus.SAVED_SAMPLE);
    PersistentObjectType fetched = 
      PersistentObject.Fetch<PersistentObjectType>(original.UID);
    // verify that the objects are identical
    AssertIdentical(original, fetched);
}

/// <span class="code-SummaryComment"><summary>
</span>
/// Tests that editing an existing object works correctly
/// <span class="code-SummaryComment"></summary>
</span>
[Test()]
public virtual void EditAndFetch() {
    PersistentObjectType modified = 
      GetSample(SampleSaveStatus.SAVED_SAMPLE);
    // edit fields 
    FillSample(modified);
    // save edits
    modified.Save();
    // make sure edits were reflected in the database
    PersistentObjectType fetched = 
      PersistentObject.Fetch<PersistentObjectType>(modified.UID);
    AssertIdentical(modified, fetched);
}

/// <span class="code-SummaryComment"><summary>
</span>
/// Tests that deletion works correctly.
/// <span class="code-SummaryComment"></summary>
</span>
/// <span class="code-SummaryComment"><remarks>
</span>
/// Expects data retrieval to fail
/// <span class="code-SummaryComment"></remarks>
</span>
[Test(), 
ExpectedException(typeof(DataNotFoundException))]
public virtual void Delete() {
    PersistentObjectType toDelete = 
      GetSample(SampleSaveStatus.SAVED_SAMPLE);
    long originalUID = toDelete.UID;
    toDelete.Delete();
    // expect failure because the object does not exist
    PersistentObject.Fetch<PersistentObjectType>(originalUID);
}

#endregion

With PersistentObjectTest doing the heavy lifting, the concrete test classes need only define how to fill a sample object and how to check if two sample objects are identical. They can also define additional sample generators, utility functions, and test methods as needed.

[TestFixture()]
public class AddressTest : PersistentObjectTest<Address> {

    public override void FillSample(Address sample) {
        base.FillSample(sample);
        Random r = new Random();
        string[] states = {"IL", "IN", "KY", "MI"};

        sample.City = "CITY" + DateTime.Now.Ticks.ToString();
        sample.State = states[r.Next(0, states.Length)];
        sample.StreetAddress = r.Next().ToString() + " Anywhere Street";
        sample.Zip = r.Next(0, 100000).ToString("00000");
    }

    public override void AssertIdentical(Address expected, Address actual) {
        base.AssertIdentical(expected, actual);
        Assert.AreEqual(expected.City, actual.City, 
          "City does not match");
        Assert.AreEqual(expected.State, actual.State,
          "State does not match");
        Assert.AreEqual(expected.StreetAddress, actual.StreetAddress, 
          "StreetAddress does not match");
        Assert.AreEqual(expected.Zip, actual.Zip, 
          "Zip does not match");
    }

}
[TestFixture()]
public class ClientTest : PersistentObjectTest<Client> {

    public override void FillSample(Client sample) {
        base.FillSample(sample);
        sample.FirstName = "FIRST" + DateTime.Now.Ticks.ToString();
        sample.MiddleName = "MIDDLE" + DateTime.Now.Ticks.ToString();
        sample.LastName = "LAST" + DateTime.Now.Ticks.ToString();
        sample.AddressUID = new AddressTest().GetSample
         (SampleSaveStatus.SAVED_SAMPLE).UID;
    }

    public override void AssertIdentical(Client expected, Client actual) {
        base.AssertIdentical(expected, actual);
        Assert.AreEqual(expected.FirstName, actual.FirstName, 
          "FirstName does not match");
        Assert.AreEqual(expected.MiddleName, actual.MiddleName, 
          "MiddleName does not match");
        Assert.AreEqual(expected.LastName, actual.LastName,
          "LastName does not match");
        Assert.AreEqual(expected.AddressUID, actual.AddressUID, 
          "AddressUID does not match");
    }
}

ClientTest's sample generator uses AddressTest.GetSample() to create a dummy Address when filling a dummy sample Client. This general pattern is used often in this type of test suite. Any test that needs a dummy object simply calls the appropriate sample generator.

When running tests, NUnit looks for any classes marked with the attribute [TestFixture()]. It creates an instance of the class and runs any methods marked with the attribute [Test()]. The [ExpectedException()] attribute tells NUnit that the given method should throw the given exception. The test code itself uses NUnit's Assert object to verify that expected properties hold.

Any test fixture that inherits from an abstract base class also "inherits"[2] any test methods. Therefore, AddressTest, a concrete test fixture, inherits the SaveAndFetch(), EditAndFetch(), and Delete() test methods from PersistentObjectTest. Note that a derived class can override these test methods if, for example, its corresponding BLL class does not support deleting:

[Test()]
public override void Delete() {
    Assert.Ignore("This object does not support deleting");
}

Inheritance

Now that we have the basic test suite implemented, say the requirements change and we need to add a class representing a preferred client that receives discounts and special credit. First we will create a PreferredClient class derived from Client:

public class PreferredClient : Client {
    private double _discountRate = 1;
    private decimal _accountCredit = 0.00M;

    //...

    public override void Save() {
        base.Save();
        // call DAL to save this object's fields
    }

    //...

}

Next, we must create a PreferredClientTest test fixture derived from ClientTest. But this causes a problem: ClientTest inherits from PersistentObjectTest<Client>, but we need PreferredClientTest to inherit indirectly from PersistentObjectTest<PreferredClient> so that PersistentObjectTest's methods use the correct type of object. The solution is to move the generic signature "down the hierarchy" to ClientTest:

/// <span class="code-SummaryComment"><summary>
</span>
/// Generic tester for classes derived from Client
/// <span class="code-SummaryComment"></summary>
</span>
public class ClientTest<DerivedClientType> 
: PersistentObjectTest<DerivedClientType> 
    where DerivedClientType : Client, new() {

    public override void FillSample(DerivedClientType sample) {
        base.FillSample(sample);
        sample.FirstName = "FIRST" + DateTime.Now.Ticks.ToString();
        sample.MiddleName = "MIDDLE" + DateTime.Now.Ticks.ToString();
        sample.LastName = "LAST" + DateTime.Now.Ticks.ToString();
        sample.AddressUID = new AddressTest().GetSample
          (SampleSaveStatus.SAVED_SAMPLE).UID;
    }

    public override void AssertIdentical
      (DerivedClientType expected, DerivedClientType actual) {
        base.AssertIdentical(expected, actual);
        Assert.AreEqual(expected.FirstName, actual.FirstName, 
          "FirstName does not match");
        Assert.AreEqual(expected.MiddleName, actual.MiddleName, 
          "MiddleName does not match");
        Assert.AreEqual(expected.LastName, actual.LastName,
          "LastName does not match");
        Assert.AreEqual(expected.AddressUID, actual.AddressUID, 
          "AddressUID does not match");
    }

}

But we need to keep the non-generic tester so Client's tests will still run:

/// <span class="code-SummaryComment"><summary>
</span>
/// Non-generic tester for base Client type
/// <span class="code-SummaryComment"></summary>
</span>
[TestFixture()]
public class ClientTest : ClientTest<Client> {
    // add Client-specific tests as needed
}

Finally, we define PreferredClientTest in terms of the generic version of ClientTest:

[TestFixture()]
public class PreferredClientTest : ClientTest<PreferredClient> {

    public override void FillSample(PreferredClient sample) {
        base.FillSample(sample);
        Random r = new Random();
        // some random dollars and cents
        sample.AccountCredit = ((Decimal)r.Next()) + .25M; 
        sample.DiscountRate = r.NextDouble();
    }

    public override void AssertIdentical
     (PreferredClient expected, PreferredClient actual) {
        base.AssertIdentical(expected, actual);
        Assert.AreEqual(expected.AccountCredit, actual.AccountCredit, 
          "AccountCredit does not match");
        Assert.AreEqual(expected.DiscountRate, actual.DiscountRate, 
          "DiscountRate does not match");
    }
}

Note that the FillSample() and AssertIdentical() methods simply extend their base class counterparts. One can easily see how this type of expansion can continue as the application grows; it is simply a matter of adding a subclass and implementing the appropriate methods.

Drawbacks

Primary Keys

This hypothetical test suite makes one glaring assumption: it assumes that PersistentObject is a valid base class for real-world classes. This assumption becomes most apparent in the Fetch/Fill methods which always take a long as a unique database identifier. Often, a real-world database will not be normalized such that all data has a bigint primary key (if only!). One can get around this problem by expanding the generic signature of PersistentObjectTest and PersistentObject.Fetch() to include the type of the derived class' unique identifier.

Dummy Data Overload

Because of its dependence on sample generators, the form of test suite creates a large amount of dummy data in the database. This is acceptable since a large part of testing a database-driven application is verifying that data is saved and retrieved correctly. However, it means that the development application must have a dedicated testing database server that is regularly reset to some known state to prevent dummy data from overshadowing valid data. Also, the recursive nature of the sample generators may make it possible to get into a never-ending sample generation cycle that could very quickly bring a database (not to mention the stack frame) to its knees.

Randomness

The implementation I have outlined assumes that random dummy data will often suffice for most tests that use the generated objects. In other words, the consumer of the sample object must ensure that a generated object meets the desired preconditions. Bounds on randomness can often be achieved with parameterized sample generators such as the following:

/// <span class="code-SummaryComment"><summary>
</span>
/// Return a client with one of the given first names
/// <span class="code-SummaryComment"></summary>
</span>
/// <span class="code-SummaryComment"><param name="firstNames">
</span>
/// The list of possible first names
/// <span class="code-SummaryComment"></param>
</span>
public static Client GetBoundedSample
  (string[] firstNames, SampleSaveStatus saveStatus) {
    Client toReturn = new ClientTest().GetSample(SampleSaveStatus.UNSAVED_SAMPLE);
    Random r = new Random();
    toReturn.FirstName = firstNames[r.Next(0, firstNames.Length)];
    if (saveStatus == SampleSaveStatus.SAVED_SAMPLE) {
        toReturn.Save();
    }
    return toReturn;
}

However, there is no general, easily-implemented way for the sample generators to control randomness or return a bounded exhaustive list of all possible samples. In fact, exhaustive test generation is an ongoing research problem.

Conclusion

The hypothetical test suite architecture that I have outlined is useful for testing tiered, database-driven applications in which reasonable, random sample data is often needed. By using test fixture inheritance and sample generators, it becomes very easy to expand the test suite as the application grows. It also reduces the amount of code needed to test the most important aspect of a database-driven application: that data travels to and from the database correctly. Variations on this testing implementation have performed well for several .NET applications with several dozen to several thousand classes.

Footnotes

  1. In reality, Save, Fill, and Delete would usually wrap protected overridable methods like DoSave, DoFill, and DoDelete. This would allow the base class to define common pre- and post-database operation steps while leaving the derived class to handle its own data. Also, Delete would usually set an "Ignore" flag rather than completely remove the data from the database. Regardless, we can ignore those complications in this article. Just assume that a derived class would override Save, Fill, and Delete in the obvious manner if the class supports the appropriate database operation.
  2. This is not true inheritance. NUnit uses reflection to find any methods marked with the attribute [Test()] regardless of where the method occurs in the class hierarchy. Also, overriding a test method does not retain the [Test()] attribute.

License

This article, along with any associated source code and files, is licensed under A Public Domain dedication

About the Author

Brett Daniel
Web Developer
United States United States
Brett Daniel is a graduate computer science student at the University of Illinois Urbana-Champaign. His work focuses on software engineering, application architecture, software testing, and programming languages.

Comments and Discussions

 
GeneralNUnit: Practical Cases Pinmemberdeadly.net15-Jul-08 0:45 
AnswerRe: NUnit: Practical Cases PinmemberBrett Daniel15-Jul-08 4:02 
GeneralRe: NUnit: Practical Cases Pinmemberdeadly.net15-Jul-08 19:11 
AnswerRe: NUnit: Practical Cases PinmemberBrett Daniel16-Jul-08 4:32 
I am a big fan and practitioner of TDD, but I understand that it may not be suitable for all developers and applications. Nevertheless, you can get a lot of benefit from what one could call Development-Driven Testing or Development-Parallel Testing.
 
It is difficult to unit test the UI, but there are a few tools that allow one to record/replay UI interactions. I have not used them to any great degree, so I cannot comment on their usefulness.
 
I have rarely found it effective to test the DAL directly. First, testing object persistence (as in the article) verifies object-relational mapping, the aspect most likely to fail as the application evolves. Second, I have found little benefit from testing the create/read/update/delete operations provided by the DAL since they are usually automatically generated or handled by a third-party library like I described in my previous reply.
 
That leaves the BLL, in particular the domain model. Like I said in my previous reply, test as much as you can, starting with what is most likely to break. What is most likely to break in the domain model? In my experience, the complex interactions between domain objects hides the most bugs. Therefore, when I need to test existing code (as opposed to when I do TDD), I have found the most benefit starting with the high-level mediator objects. I write a few tests verifying the basic functionality and as many border and exceptional cases as possible. If a test breaks, I debug to find the root cause, then write a minimal test that reveals the bug. Often, this minimal test verifies some low-level behavior of one or two domain objects. Thus, the test suite grows organically and becomes a useful repository of regression tests.
 
It is also useful to ask yourself, "what do I want to get out of these tests?" An answer of, "find as many bugs as possible" would cause you to write more thorough tests than an answer of, "verify 80% of the system's basic functionality". It is also good to decide on a coverage criteria. That is, what conditions must the tests meet for you to deem them complete? You could, for example, say that your tests should cover >80% of the basic blocks in the 80% most used domain objects. In practice, coverage criteria are often much less exact. For example, you could require a test case for each sufficiently complex method in the last few classes you wrote.
 
Unfortunately, I do not have any small test samples on hand, but there are many excellent books and websites that answer your question, "what to test?" In particular:
 
* Pragmatic Unit Testing in C# with NUnit by Andy Hunt, Dave Thomas, and Matt Hargett
* Test Driven Development: By Example by Kent Beck (a useful read even if you don't do TDD)
GeneralRe: NUnit: Practical Cases PinmemberKuldeep Vijaykumar16-Jul-08 21:34 
GeneralFew comments Pinmemberbaruchl28-Aug-06 21:41 
GeneralRe: Few comments PinmemberDarren Pruitt29-Aug-06 4:57 
GeneralRe: Few comments [modified] PinmemberBrett Daniel29-Aug-06 5:19 
GeneralRe: Few comments PinmemberDuncanJSmith21-Sep-07 12:57 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web02 | 2.8.140721.1 | Last Updated 26 Aug 2006
Article Copyright 2006 by Brett Daniel
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid