WebDrivers with culture – Testing internationalisation

WebDrivers with culture – Testing internationalisation

This entry is part 5 of 5 in the series Building a .Net Core PageFactory Framework

Setting ‘Accept-Language’ headers

When handling multiple languages, it is important to be able to verify that your site is responding as specified for different languages. In this post I’ll be looking at how to do this with Selenium WebDriver.

Whilst my code is written in C#, the concepts (and browser support) are generic and should give you enough to get this working whatever your language binding of choice.

There are two common ways to request a language on a localised website. You may use a language or country specific url, but in most cases, the language that the site returns is based upon the ‘Accept-Language’ header that is sent by your browser. This article on MDN explains how it is typically used.

Principles

  • Test that your supported localisations are returned when correctly requested and perhaps a critical path or two. I would not advise trying to test every piece of text in every supported language.
  • As always test as much as possible at lower levels. Use the System test to validate that your code is correctly configured.

WebDriver Support

As far as I can work out, setting the Accept-Language’ header for a WebDriver session is only supported on ChromeDriver and FirefoxDriver. If you know how to set it up on Internet Explorer or Safari I would be delighted to know how.

I am writing this in January 2020 with Chrome version 79 and Firefox version 72 with Selenium 3.14.159. It is very likely that the instructions here will stop working eventually.

Its all in the profiles, set in the DriverOptions

For both Firefox and Chrome, the key is to set the “intl.accept_languages” preference to the required BCP 47 tag in the profile and set the profile in the DriverOptions.

The code:

ChromeDriver

.net Core

As always you need the chromedriver in the machine path or pass the path into the chromedriver constructor, unless you are using my Selenium.WebDriver.NetCoreWebDriverFactory library

ChromeOptions options = new ChromeOptions();
options.AddUserProfilePreference("intl.accept_languages", "nl");
IWebDriver driver = new ChromeDriver({PATH to chromedriver}, options);

.net Framework

Nothing fancy here, just pass in the ChromeOptions

ChromeOptions options = new ChromeOptions();
options.AddUserProfilePreference("intl.accept_languages", "nl");
IWebDriver driver = new ChromeDriver(options);

FirefoxDriver

.net Core

This one caused me some grief. This is my final code:

FirefoxOptions options = new FirefoxOptions();
options.SetPreference("intl.accept_languages", "nl");
IWebDriver driver = new FirefoxDriver({PATH to geckodriver}, options);

.net Framework

Again noting complex here, a nice simple three liner.

FirefoxOptions options = new FirefoxOptions();
options.SetPreference("intl.accept_languages", "nl");
IWebDriver driver = new FirefoxDriver(options);

Extending my DriverFactory library for language support testing

If using Selenium.WebDriver.NetCoreWebDriverFactory, the good news about this is that we are purely dealing with DriverOptions variants. This means that we can handle this by extending my DefaultDriverOptionsFactory.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using AlexanderOnTest.NetCoreWebDriverFactory.DriverOptionsFactory;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Firefox;

namespace AlexanderOnTest.CultureTestSupport.DriverFactory
{
    public class CulturedDriverOptionsFactory : DefaultDriverOptionsFactory, IDriverOptionsFactory
    {
        /// <summary>
        /// Construct a new instance of the CulturedDriverOptionsFactory for Chrome and Firefox
        /// </summary>
        /// <param name="requestedCulture"></param>
        public CulturedDriverOptionsFactory(CultureInfo requestedCulture) : base(new Dictionary<Type, DriverOptions>())
        {
            ChromeOptions chromeCultureOptions = StaticDriverOptionsFactory.GetChromeOptions(false);
            chromeCultureOptions.AddUserProfilePreference("intl.accept_languages", requestedCulture.ToString());
            DriverOptionsDictionary.Add(typeof(ChromeOptions), chromeCultureOptions);
            
            FirefoxOptions firefoxCultureOptions = StaticDriverOptionsFactory.GetFirefoxOptions();
            firefoxCultureOptions.SetPreference("intl.accept_languages", requestedCulture.ToString());
            DriverOptionsDictionary.Add(typeof(FirefoxOptions), firefoxCultureOptions);
        }
        
        /// <summary>
        /// Return a DriverOptions instance of the correct type configured for a Local WebDriver. Only supported for Chrome and Firefox
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="headless"></param>
        /// <returns></returns>
        T IDriverOptionsFactory.GetLocalDriverOptions<T>(bool headless)
        {
            Type type = typeof(T);
            DriverOptionsDictionary.TryGetValue(type, out DriverOptions driverOptions);
            T options = driverOptions as T;
            
            if (headless)
            {
                if (options is FirefoxOptions)
                {
                    options = AddHeadlessOption(options);
                }
                else
                {
                    Trace.WriteLine("Chrome does not support language profiles in headless operation, running on screen.");
                }
            }
            return options;
        }
    }
}

In this case I have chosen to use a System.Globalization.CultureInfo object to pass in to the constructor. Whilst this seems like a good idea for type control, it does add a lot of boilerplate to create them. I will probably just pass is a string when it goes to production.

Testing

How do I know it worked?

Ok, so yes I can grab some text and compare to a known language example, but for testing whether my code has created a browser with the right settings I prefer to actually test that. In this case we execute a small JavaScript routine to check the browser preference.

IJavaScriptExecutor executor = (IJavaScriptExecutor) driver;
string language = executor.ExecuteScript("return window.navigator.userlanguage || window.navigator.language").ToString();

As always, its best to follow up some fast running unit tests (for the DriverOptionsFactory) with some system tests to verify that it actually works.

Unit tests

using System.Globalization;
using AlexanderOnTest.CultureTestSupport.DriverFactory;
using AlexanderOnTest.NetCoreWebDriverFactory.DriverOptionsFactory;
using FluentAssertions;
using NUnit.Framework;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Firefox;

namespace AlexanderOnTest.CultureTestSupport.NetCore
{
    public class CoreCultureAndHeadlessUnitTests
    {
        [Test]
        public void RequestingHeadlessDoesNotApplyHeadlessInCulturedChrome()
        {
            IDriverOptionsFactory driverOptionsFactory =
                new CulturedDriverOptionsFactory(CultureInfo.GetCultureInfoByIetfLanguageTag("nl"));
            ChromeOptions options = driverOptionsFactory.GetLocalDriverOptions<ChromeOptions>(true);

            options.Should().NotBeNull();
            options.ToString().Should().NotContain("headless");
        }

        [Test]
        public void RequestingHeadlessAppliesHeadlessInNonCulturedChrome()
        {
            IDriverOptionsFactory driverOptionsFactory = new DefaultDriverOptionsFactory();
            ChromeOptions options = driverOptionsFactory.GetLocalDriverOptions<ChromeOptions>(true);

            options.Should().NotBeNull();
            options.ToString().Should().Contain("headless");
        }

        [Test]
        public void RequestingHeadlessAppliesHeadlessInCulturedFirefox()
        {
            IDriverOptionsFactory driverOptionsFactory =
                new CulturedDriverOptionsFactory(CultureInfo.GetCultureInfoByIetfLanguageTag("nl"));
            FirefoxOptions options = driverOptionsFactory.GetLocalDriverOptions<FirefoxOptions>(true);

            options.Should().NotBeNull();
            options.ToString().Should().Contain("headless");
        }

        [Test]
        public void RequestingHeadlessAppliesHeadlessInNonCulturedFirefox()
        {
            IDriverOptionsFactory driverOptionsFactory = new DefaultDriverOptionsFactory();
            FirefoxOptions options = driverOptionsFactory.GetLocalDriverOptions<FirefoxOptions>(true);

            options.Should().NotBeNull();
            options.ToString().Should().Contain("headless");
        }
    }
}

System Tests

using System.Globalization;
using System.IO;
using System.Reflection;
using AlexanderOnTest.CultureTestSupport.DriverFactory;
using AlexanderOnTest.NetCoreWebDriverFactory;
using AlexanderOnTest.NetCoreWebDriverFactory.Config;
using AlexanderOnTest.NetCoreWebDriverFactory.Utils;
using AlexanderOnTest.NetCoreWebDriverFactory.Utils.Builders;
using AlexanderOnTest.NetCoreWebDriverFactory.WebDriverFactory;
using FluentAssertions;
using NUnit.Framework;
using OpenQA.Selenium;

namespace AlexanderOnTest.CultureTestSupport.NetCore
{
    public class CoreCultureWebDriverLauncherTests
    {
        public enum HeadlessRequested
        {
            Headless,
            OnScreen 
        }
        
        private ILocalWebDriverFactory webDriverFactory;
        private IWebDriverConfiguration driverConfig;
        private IWebDriver driver;

        [OneTimeSetUp]
        public void OneTimeSetup()
        {
            webDriverFactory = new DefaultLocalWebDriverFactory(
                new CulturedDriverOptionsFactory(new CultureInfo("nl")), 
                Path.GetDirectoryName(Assembly.GetCallingAssembly().Location), 
                new WebDriverReSizer());
        }
        
        [TestCase(Browser.Chrome, HeadlessRequested.Headless)]
        [TestCase(Browser.Firefox, HeadlessRequested.Headless)]
        [TestCase(Browser.Chrome, HeadlessRequested.OnScreen)]
        [TestCase(Browser.Firefox, HeadlessRequested.OnScreen)]
        public void CultureWebDriverCanBeLaunched(Browser testBrowser, HeadlessRequested headlessRequested)
        {
            driverConfig = WebDriverConfigurationBuilder.Start()
                .WithBrowser(testBrowser)
                .WithHeadless(headlessRequested == HeadlessRequested.Headless)
                .WithWindowSize(WindowSize.Fhd)
                .Build();
            
            driver = webDriverFactory.GetWebDriver(driverConfig);
            
            driver.Url = "https://manytools.org/http-html-text/browser-language/";

            var executor = (IJavaScriptExecutor) driver;
            
            string language = executor.ExecuteScript("return window.navigator.userlanguage || window.navigator.language").ToString();

            language.Should().Be("nl");
        }

        [TearDown]
        public void TearDown()
        {
            driver.Quit();
        }
    }
}

Gotchas

Headless ChromeDrivers don’t support profiles (and hence languages)

Playing around with this, as soon as I tried to request a headless browser, it was returning my default (en-gb) language preference. Apparently ChromeDriver doesn’t support profiles in headless mode.

This is a pain as headless running is great for enouraging devs to actually run some System tests before checking in and triggering a complete build and release cycle.

In my case I decided that the culture is more important than headless, so do not run headless if a culture is requested.

I used the Firefox code from StackOverflow and my tests throw System.TypeInitializationException before a WebDriver even starts.

All my searches came up with creating a FirefoxProfile, adding the preference and then assigning the profile to the FirefoxOptions like this:

FirefoxProfile profile = new FirefoxProfile();
profile.SetPreference("intl.accept_languages", "nl");
FirefoxOptions options = new FirefoxOptions();
options.Profile = firefoxCultureProfile;
IWebDriver driver = new FirefoxDriver({PATH to geckodriver}, options);

Unfortunately some streamlining in the libraries for .netCore 3 mean that this throws an exception due to an encoding error when trying to set the Profile.

System.TypeInitializationException : The type initializer for 'System.IO.Compression.ZipStorer' threw an exception.
  ----> System.NotSupportedException : No data is available for encoding 437. For information on defining a custom encoding, see the documentation for the Encoding.RegisterProvider method.
   at System.IO.Compression.ZipStorer.WriteEndRecord(UInt32 size, UInt32 offset)
   at System.IO.Compression.ZipStorer.Close()
   at System.IO.Compression.ZipStorer.Dispose()
   at OpenQA.Selenium.Firefox.FirefoxProfile.ToBase64String()
   at OpenQA.Selenium.Firefox.FirefoxOptions.GenerateFirefoxOptionsDictionary()

You can work around this, by importing the required encoding support, but fortunately the three line version I gave above does not require any awkward workarounds to add a profile.

The problem will also go away in Selenium 4 when it is released. For now however if you must work around this:

  • Include the System.Text.Encoding.CodePages nuget package in your project
  • Call Encoding.RegisterProvider(CodePagesEncodingProvider.Instance) before you try to work with profiles.

Did I miss anything or make a mistake?

This required a lot more work than I expected when first searching for how to do it. Please do let me know, in the comments below or on Twitter if I have made any mistakes or missed anything helpful.

I really wish this page had existed when I started with everything I needed in one place, so I will endeavour to keep it up to date.

Progress made:

  • Created the OptionsFactory code to request browsers that use Accept-Language headers to reqest localised webpages.
  • Testing for the above.

Lessons learnt:

  • This is a complex issue, and how to perform it changes frequently.
  • Only Google Chrome and Mozilla Firefox support creating WebDrivers sending “Accept-Language” headers to test your website localisation code.
  • How to edit the relevant DriverOptions to select the browser languange of choice.
  • There are gotchas with both Chrome and Firefox.
  • Testing on Chrome only works on Screen NOT headless.
  • Firefox profiles are a pain in Selenium 3 on .NET Core at present, but there is a published work around.

A reminder:

If you want to ask me a question, Twitter is undoubtedly the fastest place to get a response: My Username is @AlexanderOnTest so I am easy to find. My DMs are always open for questions, and I publicise my new blog posts there too.

Series Navigation<< C# PageFactory – Wrap your WebDriver calls
Comments are closed.