Search code examples
c#imageunit-testingdeterministic

In .Net is System.Drawing.Image.Save deterministic?


I'm trying to compare two Images via their byte content. However, they do not match.

Both images were generated from the same source image, using the same method with the same parameters. I am guessing that something in the image generation or the way I convert to a byte array is not deterministic. Does anyone know where the non-deterministic behavior is occurring and whether or not I can readily force deterministic behavior for my unit testing?

This method within my test class converts the image to a byte array - is image.Save deterministic? Is memStream.ToArray() deterministic?

private static byte[] ImageToByteArray(Image image)
{
    byte[] actualBytes;
    using (MemoryStream memStream = new MemoryStream())
    {
        image.Save(memStream, ImageFormat.Bmp);
        actualBytes = memStream.ToArray();
    }
    return actualBytes;
}

Here is the unit test, which is failing - TestImageLandscapeDesertResized_300_300 was generated from TestImageLandscapeDesert using the ImageHelper.ResizeImage(testImageLandscape, 300, 300) and then saving to a file before loading into the project's resource file. If all calls within my code were deterministic based upon my input parameters, this test should pass.

public void ResizeImage_Landscape_SmallerLandscape()
{
    Image testImageLandscape = Resources.TestImageLandscapeDesert;
    Image expectedImage = Resources.TestImageLandscapeDesertResized_300_300;
    byte[] expectedBytes = ImageToByteArray(expectedImage);
    byte[] actualBytes;
    using (Image resizedImage = ImageHelper.ResizeImage(testImageLandscape, 300, 300))
    {
        actualBytes = ImageToByteArray(resizedImage);
    }
    Assert.IsTrue(expectedBytes.SequenceEqual(actualBytes));
}

The method under test - this method will shrink the input image so its height and width are less than maxHeight and maxWidth, retaining the existing aspect ratio. Some of the graphics calls may be non-deterministic, I cannot tell from Microsoft's limited documentation.

public static Image ResizeImage(Image image, int maxWidth, int maxHeight)
{
    decimal width = image.Width;
    decimal height = image.Height;
    decimal newWidth;
    decimal newHeight;

    //Calculate new width and height
    if (width > maxWidth || height > maxHeight)
    {
        // need to preserve the original aspect ratio
        decimal originalAspectRatio = width / height;

        decimal widthReductionFactor = maxWidth / width;
        decimal heightReductionFactor = maxHeight / height;

        if (widthReductionFactor < heightReductionFactor)
        {
            newWidth = maxWidth;
            newHeight = newWidth / originalAspectRatio;
        }
        else
        {
            newHeight = maxHeight;
            newWidth = newHeight * originalAspectRatio;
        }
    }

    else
        //Return a copy of the image if smaller than allowed width and height
        return new Bitmap(image);

    //Resize image
    Bitmap bitmap = new Bitmap((int)newWidth, (int)newHeight, PixelFormat.Format48bppRgb);
    Graphics graphic = Graphics.FromImage(bitmap);
    graphic.InterpolationMode = InterpolationMode.HighQualityBicubic;
    graphic.DrawImage(image, 0, 0, (int)newWidth, (int)newHeight);
    graphic.Dispose();

    return bitmap;
}

Solution

  • This eventually worked. I don't know whether or not this is a good idea for unit tests, but with the GDI+ logic being non-deterministic (or my logic interfacing with it), this seems the best approach.

    I use MS Fakes Shimming feature to Shim the dependent calls and verify expected values are passed to the called methods. Then I call the native methods to get the required functionality for the rest of the method under test. And finally I verify a few attributes of the returned image.

    Still, I would prefer to perform a straight comparison of expected output against actual output...

    [TestMethod]
    [TestCategory("ImageHelper")]
    [TestCategory("ResizeImage")]
    public void ResizeImage_LandscapeTooLarge_SmallerLandscape()
    {
        Image testImageLandscape = Resources.TestImageLandscapeDesert;
    
        const int HEIGHT = 300;
        const int WIDTH = 300;
        const int EXPECTED_WIDTH = WIDTH;
        const int EXPECTED_HEIGHT = (int)(EXPECTED_WIDTH / (1024m / 768m));
        const PixelFormat EXPECTED_FORMAT = PixelFormat.Format48bppRgb;
        bool calledBitMapConstructor = false;
        bool calledGraphicsFromImage = false;
        bool calledGraphicsDrawImage = false;
    
        using (ShimsContext.Create())
        {
            ShimBitmap.ConstructorInt32Int32PixelFormat = (instance, w, h, f) => {
                calledBitMapConstructor = true;
                Assert.AreEqual(EXPECTED_WIDTH, w);
                Assert.AreEqual(EXPECTED_HEIGHT, h);
                Assert.AreEqual(EXPECTED_FORMAT, f);
                ShimsContext.ExecuteWithoutShims(() => {
                    ConstructorInfo constructor = typeof(Bitmap).GetConstructor(new[] { typeof(int), typeof(int), typeof(PixelFormat) });
                    Assert.IsNotNull(constructor);
                    constructor.Invoke(instance, new object[] { w, h, f });
                });
            };
            ShimGraphics.FromImageImage = i => {
                calledGraphicsFromImage = true;
                Assert.IsNotNull(i);
                return ShimsContext.ExecuteWithoutShims(() => Graphics.FromImage(i));
            };
            ShimGraphics.AllInstances.DrawImageImageInt32Int32Int32Int32 = (instance, i, x, y, w, h) => {
                calledGraphicsDrawImage = true;
                Assert.IsNotNull(i);
                Assert.AreEqual(0, x);
                Assert.AreEqual(0, y);
                Assert.AreEqual(EXPECTED_WIDTH, w);
                Assert.AreEqual(EXPECTED_HEIGHT, h);
                ShimsContext.ExecuteWithoutShims(() => instance.DrawImage(i, x, y, w, h));
            };
            using (Image resizedImage = ImageHelper.ResizeImage(testImageLandscape, HEIGHT, WIDTH))
            {
                Assert.IsNotNull(resizedImage);
                Assert.AreEqual(EXPECTED_WIDTH, resizedImage.Size.Width);
                Assert.AreEqual(EXPECTED_HEIGHT, resizedImage.Size.Height);
                Assert.AreEqual(EXPECTED_FORMAT, resizedImage.PixelFormat);
            }
        }
    
        Assert.IsTrue(calledBitMapConstructor);
        Assert.IsTrue(calledGraphicsFromImage);
        Assert.IsTrue(calledGraphicsDrawImage);
    }