diff --git a/pom.xml b/pom.xml
index 1754ae0d21800acfbff80aac77402987f375d5dd..a44e612c315457d35a4587446bdaa02b775eb37e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -9,7 +9,7 @@
 	<version>2.12.1.0</version>
 	<packaging>war</packaging>
 	<name>GWT Experiments</name>
-	<description>Experiments with GWT 2.11.0</description>
+	<description>Experiments with GWT 2.12.1</description>
 	<inceptionYear>2018</inceptionYear>
 	<organization>
 		<name>INRAE AgroClim</name>
@@ -36,9 +36,10 @@
 		<jackson.version>2.17.2</jackson.version>
 		<jersey.version>2.45</jersey.version>
 		<junit.version>5.11.3</junit.version>
-                <junit4.version>4.13.2</junit4.version>
+		<junit4.version>4.13.2</junit4.version>
 		<log4j.version>2.24.1</log4j.version>
 		<lombok.version>1.18.34</lombok.version>
+		<mockito.version>5.11.0</mockito.version>
 		<swagger.version>2.2.21</swagger.version>
 		<swagger-ui.version>5.17.14</swagger-ui.version>
 		<weld.version>3.1.9.Final</weld.version>
@@ -54,7 +55,7 @@
 
 		<!-- other properties -->
 		<analytics.tracking.id></analytics.tracking.id>
-		<javase.api.link>https://docs.oracle.com/en/java/javase/11/docs/api</javase.api.link>
+		<javase.api.link>https://docs.oracle.com/en/java/javase/17/docs/api</javase.api.link>
 
 		<!-- Maven profile -->
 		<environment>dev</environment>
@@ -377,7 +378,24 @@
 			<version>${junit.version}</version>
 			<scope>test</scope>
 		</dependency>
-
+		<dependency>
+			<groupId>org.junit.jupiter</groupId>
+			<artifactId>junit-jupiter-params</artifactId>
+			<version>${junit.version}</version>
+			<scope>test</scope>
+		</dependency>
+		<dependency>
+		    <groupId>org.mockito</groupId>
+		    <artifactId>mockito-junit-jupiter</artifactId>
+			<version>${mockito.version}</version>
+		    <scope>test</scope>
+		</dependency>
+		<dependency>
+		    <groupId>org.mockito</groupId>
+		    <artifactId>mockito-core</artifactId>
+			<version>${mockito.version}</version>
+		    <scope>test</scope>
+		</dependency>
 	</dependencies>
 
 	<build>
diff --git a/src/main/java/fr/inrae/agroclim/gwtexpe/Gwtexpe.gwt.xml b/src/main/java/fr/inrae/agroclim/gwtexpe/Gwtexpe.gwt.xml
index ff2e892cadb45bb36f8b61e08a1506b0f0b52406..9581aa68f18608f63dcaa8cfeb12ffcc71d38cd2 100644
--- a/src/main/java/fr/inrae/agroclim/gwtexpe/Gwtexpe.gwt.xml
+++ b/src/main/java/fr/inrae/agroclim/gwtexpe/Gwtexpe.gwt.xml
@@ -55,6 +55,12 @@
     <source path='client' />
     <source path='shared' />
 
+    <!-- Setting I18N -->
+    <inherits name="com.google.gwt.i18n.I18N" />
+    <extend-property name="locale" values="fr" />
+    <extend-property name="locale" values="en" />
+    <set-property-fallback name="locale" value="fr" />
+
     <!-- allow Super Dev Mode -->
     <add-linker name="xsiframe" />
 
diff --git a/src/main/java/fr/inrae/agroclim/gwtexpe/client/hello/HelloViewImpl.java b/src/main/java/fr/inrae/agroclim/gwtexpe/client/hello/HelloViewImpl.java
index 80a965ba16dfe4643480caa114f425c42d02a8bd..ddd4ffa21e8a3934132d494add24a30df1ed2cc9 100644
--- a/src/main/java/fr/inrae/agroclim/gwtexpe/client/hello/HelloViewImpl.java
+++ b/src/main/java/fr/inrae/agroclim/gwtexpe/client/hello/HelloViewImpl.java
@@ -35,6 +35,8 @@ import com.google.gwt.user.client.ui.HasText;
 import com.google.gwt.user.client.ui.TextBox;
 import com.google.gwt.user.client.ui.Widget;
 
+import fr.inrae.agroclim.gwtexpe.client.i18n.AppMessages;
+
 /**
  * Implementation of HelloView.
  */
@@ -46,6 +48,11 @@ public class HelloViewImpl extends Composite implements HelloView {
     interface HelloViewImplUiBinder extends UiBinder<Widget, HelloViewImpl> {
     }
 
+    /**
+     * I18n messages.
+     */
+    private static final AppMessages MSGS = GWT.create(AppMessages.class);
+
     /**
      * UI binder.
      */
@@ -64,6 +71,12 @@ public class HelloViewImpl extends Composite implements HelloView {
     @UiField
     protected HasText errorLabel;
 
+    /**
+     * Label to display greetings.
+     */
+    @UiField
+    protected HasText greetings;
+
     /**
      * Presenter of Hello.
      */
@@ -132,6 +145,7 @@ public class HelloViewImpl extends Composite implements HelloView {
 
     @Override
     public final void setName(final String name) {
+        greetings.setText(MSGS.hello(name));
         textBox.setText(name);
     }
 
diff --git a/src/main/java/fr/inrae/agroclim/gwtexpe/client/hello/HelloViewImpl.ui.xml b/src/main/java/fr/inrae/agroclim/gwtexpe/client/hello/HelloViewImpl.ui.xml
index e76a00b7caecd0794d484c32ae816ffe5f1f4026..10d11c192567b0cb874980c7a7fb658529ba0073 100644
--- a/src/main/java/fr/inrae/agroclim/gwtexpe/client/hello/HelloViewImpl.ui.xml
+++ b/src/main/java/fr/inrae/agroclim/gwtexpe/client/hello/HelloViewImpl.ui.xml
@@ -24,11 +24,14 @@
 <!DOCTYPE ui:UiBinder SYSTEM "http://dl.google.com/gwt/DTD/xhtml.ent">
 <ui:UiBinder xmlns:ui="urn:ui:com.google.gwt.uibinder"
              xmlns:g="urn:import:com.google.gwt.user.client.ui">
+     <ui:with field="csts"
+             type="fr.inrae.agroclim.gwtexpe.client.i18n.AppConstants" />
     <g:VerticalPanel>
-        <g:Label text="Web Application Starter Project" />
-        <g:Label text="Please enter your name:" />
+        <g:Label text="{csts.appName}" />
+        <g:Label ui:field="greetings" />
+        <g:Label text="{csts.enterName}" />
         <g:TextBox ui:field="textBox" />
         <g:Label ui:field="errorLabel" />
-        <g:Button ui:field="button" text="Send" />
+        <g:Button ui:field="button" text="{csts.send}" />
     </g:VerticalPanel>
 </ui:UiBinder>
diff --git a/src/main/java/fr/inrae/agroclim/gwtexpe/client/i18n/AppConstants.java b/src/main/java/fr/inrae/agroclim/gwtexpe/client/i18n/AppConstants.java
new file mode 100644
index 0000000000000000000000000000000000000000..05e776864e9c2b0b96a8539240c773d912c439a7
--- /dev/null
+++ b/src/main/java/fr/inrae/agroclim/gwtexpe/client/i18n/AppConstants.java
@@ -0,0 +1,28 @@
+package fr.inrae.agroclim.gwtexpe.client.i18n;
+
+/**
+ * Interface to represent the constants contained in resource bundle:
+ * 'fr/inrae/agroclim/gwt-expe/client/i18n/AppConstants.properties'.
+ *
+ * @author Olivier Maury
+ */
+public interface AppConstants extends com.google.gwt.i18n.client.ConstantsWithLookup {
+
+    /**
+     * @return translation
+     */
+    @DefaultStringValue("Web Application Starter Project")
+    String appName();
+
+    /**
+     * @return translation
+     */
+    @DefaultStringValue("Please enter your name:")
+    String enterName();
+
+    /**
+     * @return translation
+     */
+    @DefaultStringValue("Send")
+    String send();
+}
diff --git a/src/main/java/fr/inrae/agroclim/gwtexpe/client/i18n/AppMessages.java b/src/main/java/fr/inrae/agroclim/gwtexpe/client/i18n/AppMessages.java
new file mode 100644
index 0000000000000000000000000000000000000000..44258c7a56fe9b25fb73f977588f8b20dde7ad32
--- /dev/null
+++ b/src/main/java/fr/inrae/agroclim/gwtexpe/client/i18n/AppMessages.java
@@ -0,0 +1,19 @@
+package fr.inrae.agroclim.gwtexpe.client.i18n;
+
+import com.google.gwt.i18n.client.Messages;
+
+/**
+ * Internationalization messages.
+ *
+ * @author Olivier Maury
+ */
+public interface AppMessages extends Messages {
+
+    /**
+     * @param name user name
+     * @return translation
+     */
+    @DefaultMessage("Hello {0}")
+    String hello(String name);
+
+}
diff --git a/src/main/java/fr/inrae/agroclim/gwtexpe/client/i18n/package-info.java b/src/main/java/fr/inrae/agroclim/gwtexpe/client/i18n/package-info.java
new file mode 100644
index 0000000000000000000000000000000000000000..d32c2bde971a8cb6b61f735b02a517f9a1e3a608
--- /dev/null
+++ b/src/main/java/fr/inrae/agroclim/gwtexpe/client/i18n/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Internationalization classes.
+ */
+package fr.inrae.agroclim.gwtexpe.client.i18n;
diff --git a/src/main/java/fr/inrae/agroclim/gwtexpe/server/GreetingServiceImpl.java b/src/main/java/fr/inrae/agroclim/gwtexpe/server/GreetingServiceImpl.java
index 06114963792c70e40433bd30545c49302e60cbe9..7ec2a0bda030eca0594162acaad1ebc0cc641ff8 100644
--- a/src/main/java/fr/inrae/agroclim/gwtexpe/server/GreetingServiceImpl.java
+++ b/src/main/java/fr/inrae/agroclim/gwtexpe/server/GreetingServiceImpl.java
@@ -1,5 +1,7 @@
 package fr.inrae.agroclim.gwtexpe.server;
 
+import java.util.Locale;
+
 /*-
  * #%L
  * GWT Experiments
@@ -27,6 +29,7 @@ import javax.enterprise.event.Event;
 import javax.inject.Inject;
 import javax.inject.Named;
 import javax.servlet.annotation.WebServlet;
+import javax.servlet.http.HttpServletRequest;
 
 import com.google.gwt.user.server.rpc.RemoteServiceServlet;
 
@@ -49,6 +52,12 @@ implements GreetingService {
      */
     private static final long serialVersionUID = 4079295733539308238L;
 
+    /**
+     * HTTP request.
+     */
+    @Inject
+    private HttpServletRequest request;
+
     /**
      * DAO for Person.
      */
@@ -80,8 +89,8 @@ implements GreetingService {
         if (html == null) {
             return null;
         }
-        return html.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
-                .replaceAll(">", "&gt;");
+        return html.replace("&", "&amp;").replace("<", "&lt;")
+                .replace(">", "&gt;");
     }
 
     @Override
@@ -122,10 +131,10 @@ implements GreetingService {
         final String msg = escapeHtml(input);
         userAgent = escapeHtml(userAgent);
 
-        return String.format(
-                "Hello %s! I know you as %s.<br/> I am running %s.<br><br>"
-                        + "It looks like you are using:<br>%s",
-                        msg, known, serverInfo, userAgent);
+        final Locale locale = LocaleUtils.getLocale(request);
+        final I18n i18n = new I18n("fr.inrae.agroclim.gwtexpe.server.i18n", locale);
+
+        return i18n.format("hello", msg, known, serverInfo, userAgent);
     }
 
     @Override
diff --git a/src/main/java/fr/inrae/agroclim/gwtexpe/server/I18n.java b/src/main/java/fr/inrae/agroclim/gwtexpe/server/I18n.java
new file mode 100644
index 0000000000000000000000000000000000000000..a70a18ddf547532de7753b81f630ce071494be62
--- /dev/null
+++ b/src/main/java/fr/inrae/agroclim/gwtexpe/server/I18n.java
@@ -0,0 +1,250 @@
+package fr.inrae.agroclim.gwtexpe.server;
+
+import java.text.MessageFormat;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.ResourceBundle;
+import java.util.Set;
+import java.util.function.BiFunction;
+import java.util.function.BiPredicate;
+import java.util.stream.Collectors;
+
+import lombok.Getter;
+import lombok.NonNull;
+
+/**
+ * Localized messages with plural handling à la GWT.
+ *
+ * @author Olivier Maury
+ */
+public class I18n {
+    /**
+     * Accepted operators, ordered.
+     */
+    public enum Operator implements BiFunction<Integer, Integer, Boolean> {
+        /**
+         * Equals.
+         */
+        AEQ("=", Objects::equals),
+        /**
+         * Inferior or equals.
+         */
+        BINFEQ("<=", (a, b) -> a <= b),
+        /**
+         * Strictly inferior.
+         */
+        CINF("<", (a, b) -> a < b),
+        /**
+         * Superior or equals.
+         */
+        DSUPEQ(">=", (a, b) -> a >= b),
+        /**
+         * Strictly superior.
+         */
+        ESUP(">", (a, b) -> a > b);
+
+        /**
+         * Guess the right operator in the string comparison.
+         *
+         * @param string string comparison (eg.: ">=10")
+         * @return operator matching the string comparison
+         */
+        static Optional<Operator> extract(final String string) {
+            for (final Operator op : values()) {
+                if (string.startsWith(op.symbol)) {
+                    return Optional.of(op);
+                }
+            }
+            return Optional.empty();
+        }
+        /**
+         * Comparison function of the operator.
+         */
+        private final BiPredicate<Integer, Integer> function;
+        /**
+         * String representation of the operator.
+         */
+        private final String symbol;
+        /**
+         * Constructor.
+         *
+         * @param string String representation of the operator.
+         * @param func Comparison function of the operator.
+         */
+        Operator(final String string,
+                final BiPredicate<Integer, Integer> func) {
+            symbol = string;
+            function = func;
+        }
+
+        @Override
+        public Boolean apply(final Integer arg0, final Integer arg1) {
+            return function.test(arg0, arg1);
+        }
+
+        /**
+         * @return length of string representation
+         */
+        public int getLength() {
+            return symbol.length();
+        }
+    }
+
+    /**
+     * Bundle name.
+     */
+    public static final String BUNDLE_NAME = "fr.inrae.agroclim.gwtexpe.server.i18n";
+
+    /**
+     * Check if the comparison string matches the value.
+     *
+     * @param comparison comparison string (eg.: ">10")
+     * @param plural value
+     * @return if the comparison string matches the value.
+     */
+    static boolean matches(final String comparison, final int plural) {
+        final var operator = Operator.extract(comparison);
+        if (operator.isPresent()) {
+            final var op = operator.get();
+            var val = comparison.substring(op.getLength());
+            if (val != null) {
+                val = val.trim();
+                if (!val.isEmpty()) {
+                    final var value = Integer.valueOf(val);
+                    return op.apply(plural, value);
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Fallback resources from .properties file, in case of missing translation.
+     */
+    private final ResourceBundle fallbackResources;
+
+    /**
+     * Translation keys.
+     */
+    private final Set<String> keys = new HashSet<>();
+
+    /**
+     * Requested locale.
+     */
+    @Getter
+    private final Locale locale;
+
+    /**
+     * Resources from .properties file.
+     */
+    private final ResourceBundle resources;
+
+    /**
+     * Constructor.
+     *
+     * @param rb the bundle.
+     */
+    public I18n(final ResourceBundle rb) {
+        resources = rb;
+        locale = rb.getLocale();
+        final String bundleName = rb.getBaseBundleName();
+        if (bundleName != null) {
+            fallbackResources = ResourceBundle.getBundle(bundleName, Locale.ROOT);
+        } else {
+            fallbackResources = null;
+        }
+    }
+
+    /**
+     * Constructor.
+     *
+     * @param bundleName      Path of .property resource.
+     * @param requestedLocale The requested locale for the bundle.
+     */
+    public I18n(@NonNull final String bundleName, @NonNull final Locale requestedLocale) {
+        locale = requestedLocale;
+        fallbackResources = ResourceBundle.getBundle(bundleName, Locale.ROOT);
+        final ResourceBundle res = ResourceBundle.getBundle(bundleName, locale);
+        if (res.getLocale().equals(locale)) {
+            resources = res;
+        } else {
+            resources = fallbackResources;
+        }
+        keys.addAll(Collections.list(resources.getKeys()));
+        keys.addAll(Collections.list(fallbackResources.getKeys()));
+    }
+
+    /**
+     * Return message with inlined arguments.
+     *
+     * @param plural value for plural form
+     * @param key message key
+     * @param messageArguments arguments for the message.
+     * @return message with arguments or exclamation message
+     */
+    public String format(final int plural, final String key,
+            final Object... messageArguments) {
+        String keyWithSuffix;
+
+        // the suffix for the value
+        keyWithSuffix = key + "[=" + plural + "]";
+        if (getKeys().contains(keyWithSuffix)) {
+            return format(keyWithSuffix, messageArguments);
+        }
+
+        // with comparators <, <=, >, >=
+        final var suffixes = getKeys().stream()
+                .filter(k -> k.startsWith(key + "[") && k.endsWith("]"))
+                .map(k -> k.substring(key.length() + 1, k.length() - 1))
+                .collect(Collectors.toList());
+        for (final String suf : suffixes) {
+            if (matches(suf, plural)) {
+                keyWithSuffix = key + "[" + suf + "]";
+                return format(keyWithSuffix, messageArguments);
+            }
+        }
+        // if not defined, used default
+        return format(key, messageArguments);
+    }
+
+    /**
+     * Return message with inlined arguments.
+     *
+     * @param key message key
+     * @param messageArguments arguments for the message.
+     * @return message with arguments or exclamation message
+     */
+    public String format(final String key, final Object... messageArguments) {
+        final String format = this.get(key);
+        final MessageFormat messageFormat = new MessageFormat(format, locale);
+        return messageFormat.format(messageArguments);
+    }
+
+    /**
+     * Retrieve message from key.
+     *
+     * @param key message key
+     * @return message value or exclamation message
+     */
+    public String get(final String key) {
+        if (resources.containsKey(key)) {
+            return resources.getString(key);
+        }
+        if (fallbackResources != null && fallbackResources.containsKey(key)) {
+            return fallbackResources.getString(key);
+        }
+        return "!" + key + "!";
+    }
+
+
+    /**
+     * @return all keys in the .properties file
+     */
+    public Set<String> getKeys() {
+        return Collections.unmodifiableSet(keys);
+    }
+
+}
diff --git a/src/main/java/fr/inrae/agroclim/gwtexpe/server/LocaleUtils.java b/src/main/java/fr/inrae/agroclim/gwtexpe/server/LocaleUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..f8ff189708a2b1091ed2264aedb88139d8c9b7b0
--- /dev/null
+++ b/src/main/java/fr/inrae/agroclim/gwtexpe/server/LocaleUtils.java
@@ -0,0 +1,77 @@
+package fr.inrae.agroclim.gwtexpe.server;
+
+import java.util.Locale;
+import java.util.Set;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Locale utils for servlet and server side service.
+ *
+ * @author Olivier Maury
+ */
+public interface LocaleUtils {
+    /**
+     * @return The default language for the application: English.
+     */
+    static Locale getDefaultLocale() {
+        return Locale.ENGLISH;
+    }
+
+    /**
+     * Guess the allowed locale according to URL parameter, cookie and request.
+     *
+     * @param request an {@link HttpServletRequest} object that contains the request
+     *                the client has made of the servlet
+     * @return the locale from the user request
+     */
+    static Locale getLocale(final HttpServletRequest request) {
+        if (request == null) {
+            return getDefaultLocale();
+        }
+        // 1. URL parameter
+        final String localeParam = request.getParameter("locale");
+        if (localeParam != null) {
+            final var found = getLocale(localeParam);
+            if (found != null) {
+                return found;
+            }
+        }
+        // 2. HTTP header
+        if (request.getLocale() == null) {
+            return getDefaultLocale();
+        }
+        if (Locale.FRANCE == request.getLocale()) {
+            return Locale.FRENCH;
+        }
+        if (getLocales().contains(request.getLocale())) {
+            return request.getLocale();
+        }
+        return getDefaultLocale();
+    }
+
+    /**
+     * Guess the allowed locale according to the language tag.
+     *
+     * @param tag language tag
+     * @return found allowed language or null
+     */
+    static Locale getLocale(final String tag) {
+        if (tag.startsWith("fr")) {
+            return Locale.FRENCH;
+        }
+        final var found = Locale.forLanguageTag(tag);
+        if (found == null || !getLocales().contains(found)) {
+            return Locale.ENGLISH;
+        }
+        return found;
+    }
+
+    /**
+     * @return supported locales.
+     */
+    static Set<Locale> getLocales() {
+        return Set.of(getDefaultLocale(), Locale.FRENCH);
+    }
+
+}
diff --git a/src/main/resources/fr/inrae/agroclim/gwtexpe/client/i18n/AppConstants_fr.properties b/src/main/resources/fr/inrae/agroclim/gwtexpe/client/i18n/AppConstants_fr.properties
new file mode 100644
index 0000000000000000000000000000000000000000..047d082ec222d1a0959e63fb43d3a12231475719
--- /dev/null
+++ b/src/main/resources/fr/inrae/agroclim/gwtexpe/client/i18n/AppConstants_fr.properties
@@ -0,0 +1,4 @@
+# Ce fichier est encodé en UTF-8.
+appName = Projet de démarrage d'application web
+enterName = Veuillez saisir votre nom\u00a0:
+send = Envoyer
diff --git a/src/main/resources/fr/inrae/agroclim/gwtexpe/client/i18n/AppMessages_fr.properties b/src/main/resources/fr/inrae/agroclim/gwtexpe/client/i18n/AppMessages_fr.properties
new file mode 100644
index 0000000000000000000000000000000000000000..fc4ede5b51c5b861807a2282595e3d1a430fc2fb
--- /dev/null
+++ b/src/main/resources/fr/inrae/agroclim/gwtexpe/client/i18n/AppMessages_fr.properties
@@ -0,0 +1,2 @@
+# Ce fichier est encodé en UTF-8.
+hello = Bonjour {0}
diff --git a/src/main/resources/fr/inrae/agroclim/gwtexpe/server/i18n.properties b/src/main/resources/fr/inrae/agroclim/gwtexpe/server/i18n.properties
new file mode 100644
index 0000000000000000000000000000000000000000..843db7bd8773d6094fcda7d23bf6f370f610a570
--- /dev/null
+++ b/src/main/resources/fr/inrae/agroclim/gwtexpe/server/i18n.properties
@@ -0,0 +1,2 @@
+# Ce fichier est encodé en UTF-8.
+hello = Hello {0}! I know you as {1}.<br/> I am running {2}.<br><br>It looks like you are using:<br>{3}
diff --git a/src/main/resources/fr/inrae/agroclim/gwtexpe/server/i18n_fr.properties b/src/main/resources/fr/inrae/agroclim/gwtexpe/server/i18n_fr.properties
new file mode 100644
index 0000000000000000000000000000000000000000..39038e6be915cd0860def746858107d3190d6cce
--- /dev/null
+++ b/src/main/resources/fr/inrae/agroclim/gwtexpe/server/i18n_fr.properties
@@ -0,0 +1,2 @@
+# Ce fichier est encodé en UTF-8.
+hello = Bonjour {0}\u00a0! Je vous reconnais comme {1}.<br/>Je tourne sous {2}.<br><br>Il semble que vous utilisez\u00a0:<br>{3}
diff --git a/src/test/java/fr/inrae/agroclim/gwtexpe/server/I18nTest.java b/src/test/java/fr/inrae/agroclim/gwtexpe/server/I18nTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..2159f83b485c04460a909ecec0538c52066212a3
--- /dev/null
+++ b/src/test/java/fr/inrae/agroclim/gwtexpe/server/I18nTest.java
@@ -0,0 +1,159 @@
+package fr.inrae.agroclim.gwtexpe.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.ResourceBundle;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Test plural form handling with I18n.
+ */
+class I18nTest {
+
+
+    /**
+     * Key for plural messages to test.
+     */
+    private static final String KEY = "cartItems";
+    /**
+     * Name of test bundle.
+     */
+    private static final String NAME = "fr.inrae.agroclim.gwtexpe.server.test";
+
+    /**
+     * {@link MethodSource} annotation marks this method as parameters provider.
+     *
+     * @return combinaisons of nb, key, expected translation
+     */
+    static Stream<Arguments> formatPluralData() {
+        return Stream.of(//
+                Arguments.of(1, KEY, "Il y a un produit dans votre panier."), //
+                Arguments.of(2, KEY, "Il y a deux produits dans votre panier."), //
+                Arguments.of(3, KEY, "Il y a 3 produits dans votre panier, ce qui est peu."), //
+                Arguments.of(42, KEY, "Il y a 42 produits dans votre panier, ce qui est un nombre spécial."), //
+                Arguments.of(101, KEY, "Il y a 101 produits dans votre panier, ce qui est beaucoup."), //
+                Arguments.of(1_314, KEY, "Il y a 1 314 produits dans votre panier, ce qui est beaucoup."), //
+                Arguments.of(1, "cats", "Un chat"), //
+                // Variation is not present, default value.
+                Arguments.of(2, "cats", "2 chats"), //
+                Arguments.of(1, "comparison", "Valeur == 1"), //
+                Arguments.of(10, "comparison", "Valeur >= 10"), //
+                Arguments.of(11, "comparison", "Valeur > 10"), //
+                Arguments.of(-10, "comparison", "Valeur <= -10"), //
+                Arguments.of(-101, "comparison", "Valeur < -100") //
+                );
+    }
+
+    /**
+     * French translations.
+     */
+    private final I18n res = new I18n(NAME, Locale.FRENCH);
+
+    @Test
+    void contructorWithResourceBundle() {
+        final var bundle = ResourceBundle.getBundle(NAME, Locale.FRENCH);
+        final var i18n = new I18n(bundle);
+        String actual;
+        String expected;
+        actual = i18n.get("error.title");
+        expected = "Erreur";
+        assertEquals(expected, actual);
+
+        actual = i18n.get("not.translated");
+        expected = "This message is not translated.";
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    void format() {
+        final var actual = res.format("warning.missing", "climat", 1, "WIND");
+        final var expected = "climat : ligne 1 : WIND manque.";
+        assertEquals(expected, actual);
+    }
+
+    @ParameterizedTest(name = "formatPluralDefault {index}: {0} {1}")
+    @MethodSource("formatPluralData")
+    void formatPluralDefault(final int nb, final String key, final String expected) {
+        final var actual = res.format(nb, key, nb);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    void formatPluralSpecialDefaultProperties() {
+        final int nb = 42;
+        final var korea = new I18n(NAME, Locale.KOREA);
+        final var actual = korea.format(nb, KEY, nb);
+        final var expected = "There are 42 items in your cart, a special number!";
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    void getStringDefault() {
+        final var actual = res.get("not.translated");
+        final var expected = "This message is not translated.";
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    void getStringExisting() {
+        final var actual = res.get("error.title");
+        final var expected = "Erreur";
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    void matches() {
+        final Map<String, Integer> doesMatch = new HashMap<>();
+        doesMatch.put("=1", 1);
+        doesMatch.put(">0", 1);
+        doesMatch.put(">=0", 1);
+        doesMatch.put(">=1", 1);
+        doesMatch.put("<0", -1);
+        doesMatch.put("<=0", -1);
+        doesMatch.put("<=1", -1);
+        doesMatch.put("<-100", -101);
+        doesMatch.forEach((comparison, plural) -> {
+            final boolean actual = I18n.matches(comparison, plural);
+            assertTrue(actual, comparison + " must be true for " + plural);
+        });
+        final Map<String, Integer> dontMatch = new HashMap<>();
+        dontMatch.put("=0", 1);
+        dontMatch.put(">1", 1);
+        dontMatch.put(">2", 1);
+        dontMatch.put(">=2", 1);
+        dontMatch.put("<-1", -1);
+        dontMatch.put("<-2", -1);
+        dontMatch.put("<=-2", -1);
+        dontMatch.forEach((comparison, plural) -> {
+            final boolean actual = I18n.matches(comparison, plural);
+            assertFalse(actual, comparison + " must be false for " + plural);
+        });
+    }
+
+    @Test
+    void operatorExtract() {
+        final Map<String, I18n.Operator> operators = new HashMap<>();
+        operators.put("1", null);
+        operators.put("=1", I18n.Operator.AEQ);
+        operators.put(">0", I18n.Operator.ESUP);
+        operators.put(">=0", I18n.Operator.DSUPEQ);
+        operators.put(">=1", I18n.Operator.DSUPEQ);
+        operators.put("<0", I18n.Operator.CINF);
+        operators.put("<=0", I18n.Operator.BINFEQ);
+        operators.forEach((comparison, expected) -> {
+            final I18n.Operator actual = I18n.Operator.extract(comparison) //
+                    .orElse(null);
+            assertEquals(expected, actual, expected + " must be extracted from " + comparison);
+        });
+    }
+}
diff --git a/src/test/java/fr/inrae/agroclim/gwtexpe/server/LocaleUtilsTest.java b/src/test/java/fr/inrae/agroclim/gwtexpe/server/LocaleUtilsTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..d4a5eaedd02cc99fdc5480cadbe03d4a651adad1
--- /dev/null
+++ b/src/test/java/fr/inrae/agroclim/gwtexpe/server/LocaleUtilsTest.java
@@ -0,0 +1,87 @@
+package fr.inrae.agroclim.gwtexpe.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.when;
+
+import java.util.Locale;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+/**
+ * JUnit test of {@link LocaleUtils}.
+ *
+ * @author Olivier Maury
+ */
+@ExtendWith(MockitoExtension.class)
+class LocaleUtilsTest {
+
+    /**
+     * HTTP servlet request with "locale" parameter.
+     */
+    private final HttpServletRequest request;
+
+    LocaleUtilsTest(@Mock final HttpServletRequest mockedRequest) {
+        this.request = mockedRequest;
+    }
+
+    @Test
+    void getLocaleDefault() {
+        when(request.getParameter("locale")).thenReturn(null);
+        final Locale actual = LocaleUtils.getLocale(request);
+        final Locale expected = LocaleUtils.getDefaultLocale();
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    void getLocaleEnParam() {
+        when(request.getParameter("locale")).thenReturn("en");
+        final Locale actual = LocaleUtils.getLocale(request);
+        final Locale expected = Locale.ENGLISH;
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    void getLocaleEoParam() {
+        when(request.getParameter("locale")).thenReturn("eo");
+        final Locale actual = LocaleUtils.getLocale(request);
+        final Locale expected = LocaleUtils.getDefaultLocale();
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    void getLocaleFrFR() {
+        final Locale actual = LocaleUtils.getLocale(Locale.FRANCE.toLanguageTag());
+        final Locale expected = Locale.FRENCH;
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    void getLocaleFrFRLocale() {
+        when(request.getLocale()).thenReturn(Locale.FRANCE);
+        final Locale actual = LocaleUtils.getLocale(request);
+        final Locale expected = Locale.FRENCH;
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    void getLocaleFrFRParam() {
+        when(request.getParameter("locale")).thenReturn("fr_FR");
+        final Locale actual = LocaleUtils.getLocale(request);
+        final Locale expected = Locale.FRENCH;
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    void getLocaleFrParam() {
+        when(request.getParameter("locale")).thenReturn("fr");
+        final Locale actual = LocaleUtils.getLocale(request);
+        final Locale expected = Locale.FRENCH;
+        assertEquals(expected, actual);
+    }
+
+}
diff --git a/src/test/resources/fr/inrae/agroclim/gwtexpe/server/test.properties b/src/test/resources/fr/inrae/agroclim/gwtexpe/server/test.properties
new file mode 100644
index 0000000000000000000000000000000000000000..15a4e5393a7c16528a914ad4ba2fc18f4e9abc09
--- /dev/null
+++ b/src/test/resources/fr/inrae/agroclim/gwtexpe/server/test.properties
@@ -0,0 +1,16 @@
+cartItems=There are {0,number} items in your cart.
+cartItems=There are {0,number} items in your cart, which are many.
+cartItems[\=0]=There are no items in your cart.
+cartItems[\=1]=There is one item in your cart.
+cartItems[\=2]=There are two items in your cart.
+cartItems[\=3]=There are {0,number} items in your cart, which are few.
+cartItems[\=42]=There are {0,number} items in your cart, a special number!
+cats={0,number} cats
+cats[\=1]=One cat
+date.iso_local_date_time=2018-08-27T11:46:30
+date.yyyyMMddHHmmss=20180827114630
+not.translated=This message is not translated.
+error.title=Error
+warning.missing={0}: line {1}: {2} is missing
+build.date=2018-08-28 14:59:30
+version=1.2.3
\ No newline at end of file
diff --git a/src/test/resources/fr/inrae/agroclim/gwtexpe/server/test_fr.properties b/src/test/resources/fr/inrae/agroclim/gwtexpe/server/test_fr.properties
new file mode 100644
index 0000000000000000000000000000000000000000..4a1e24b43fbd13b7ab1d5876f54096f8f36dd19b
--- /dev/null
+++ b/src/test/resources/fr/inrae/agroclim/gwtexpe/server/test_fr.properties
@@ -0,0 +1,17 @@
+cartItems=Il y a {0,number} produits dans votre panier.
+cartItems=Il y a {0,number} produits dans votre panier, ce qui est beaucoup.
+cartItems[\=0]=Il n'y a aucun produit dans votre panier.
+cartItems[\=1]=Il y a un produit dans votre panier.
+cartItems[\=2]=Il y a deux produits dans votre panier.
+cartItems[\=3]=Il y a {0,number} produits dans votre panier, ce qui est peu.
+cartItems[\=42]=Il y a 42 produits dans votre panier, ce qui est un nombre spécial.
+cats={0,number} chats
+cats[\=1]=Un chat
+comparison=Valeur par défaut pour {0,number}
+comparison[<-100]=Valeur < -100
+comparison[<\=-10]=Valeur <= -10
+comparison[\=1]=Valeur == 1
+comparison[>\=10]=Valeur >= 10
+comparison[>10]=Valeur > 10
+error.title=Erreur
+warning.missing={0}\u00a0: ligne {1}\u00a0: {2} manque.