diff --git a/init b/init
index 036e7ee20..9187ee5aa 100755
--- a/init
+++ b/init
@@ -93,6 +93,10 @@ setup_virtualenv tests/stress
 setup_virtualenv tests/integration/ldap
 
 # Setup tests dependencies.
+# NOTE: This is commented out because of the build order (at the time of
+# execution mgclient is not built yet) which makes this setup to fail. mgclient
+# is built during the make phase. The tests/setup.sh is called under GHA CI
+# jobs.
 # cd tests
 # ./setup.sh
 # cd ..
diff --git a/src/memgraph.cpp b/src/memgraph.cpp
index 33899832d..1c8750553 100644
--- a/src/memgraph.cpp
+++ b/src/memgraph.cpp
@@ -53,6 +53,7 @@
 #include "query/frontend/ast/ast.hpp"
 #include "query/interpreter.hpp"
 #include "query/plan/operator.hpp"
+#include "query/procedure/callable_alias_mapper.hpp"
 #include "query/procedure/module.hpp"
 #include "query/procedure/py_module.hpp"
 #include "requests/requests.hpp"
@@ -355,6 +356,12 @@ DEFINE_VALIDATED_string(query_modules_directory, "",
                           return true;
                         });
 
+// NOLINTNEXTLINE (cppcoreguidelines-avoid-non-const-global-variables)
+DEFINE_string(query_callable_mappings_path, "",
+              "The path to mappings that describes aliases to callables in cypher queries in the form of key-value "
+              "pairs in a json file. With this option query module procedures that do not exist in memgraph can be "
+              "mapped to ones that exist.");
+
 // Logging flags
 // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
 DEFINE_HIDDEN_bool(also_log_to_stderr, false, "Log messages go to stderr in addition to logfiles");
@@ -569,6 +576,7 @@ class BoltSession final : public memgraph::communication::bolt::Session<memgraph
     if (user_) {
       username = &user_->username();
     }
+
 #ifdef MG_ENTERPRISE
     if (memgraph::license::global_license_checker.IsEnterpriseValidFast()) {
       audit_log_->Record(endpoint_.address().to_string(), user_ ? *username : "", query,
@@ -954,6 +962,7 @@ int main(int argc, char **argv) {
 
   memgraph::query::procedure::gModuleRegistry.SetModulesDirectory(query_modules_directories, FLAGS_data_directory);
   memgraph::query::procedure::gModuleRegistry.UnloadAndLoadModulesFromDirectories();
+  memgraph::query::procedure::gCallableAliasMapper.LoadMapping(FLAGS_query_callable_mappings_path);
 
   memgraph::glue::AuthQueryHandler auth_handler(&auth, FLAGS_auth_user_or_role_name_regex);
   memgraph::glue::AuthChecker auth_checker{&auth};
diff --git a/src/query/CMakeLists.txt b/src/query/CMakeLists.txt
index 63eedf5d2..b78c5b74d 100644
--- a/src/query/CMakeLists.txt
+++ b/src/query/CMakeLists.txt
@@ -27,6 +27,7 @@ set(mg_query_sources
     procedure/mg_procedure_helpers.cpp
     procedure/module.cpp
     procedure/py_module.cpp
+    procedure/callable_alias_mapper.cpp
     serialization/property_value.cpp
     stream/streams.cpp
     stream/sources.cpp
diff --git a/src/query/frontend/ast/ast.hpp b/src/query/frontend/ast/ast.hpp
index e30ffdb82..90a787005 100644
--- a/src/query/frontend/ast/ast.hpp
+++ b/src/query/frontend/ast/ast.hpp
@@ -2253,6 +2253,7 @@ class CallProcedure : public memgraph::query::Clause {
   memgraph::query::Expression *memory_limit_{nullptr};
   size_t memory_scale_{1024U};
   bool is_write_;
+  bool void_procedure_{false};
 
   CallProcedure *Clone(AstStorage *storage) const override {
     CallProcedure *object = storage->Create<CallProcedure>();
@@ -2269,6 +2270,7 @@ class CallProcedure : public memgraph::query::Clause {
     object->memory_limit_ = memory_limit_ ? memory_limit_->Clone(storage) : nullptr;
     object->memory_scale_ = memory_scale_;
     object->is_write_ = is_write_;
+    object->void_procedure_ = void_procedure_;
     return object;
   }
 
diff --git a/src/query/frontend/ast/cypher_main_visitor.cpp b/src/query/frontend/ast/cypher_main_visitor.cpp
index ef0a0aad9..33647b0fa 100644
--- a/src/query/frontend/ast/cypher_main_visitor.cpp
+++ b/src/query/frontend/ast/cypher_main_visitor.cpp
@@ -35,6 +35,7 @@
 #include "query/frontend/ast/ast_visitor.hpp"
 #include "query/frontend/parsing.hpp"
 #include "query/interpret/awesome_memgraph_functions.hpp"
+#include "query/procedure/callable_alias_mapper.hpp"
 #include "query/procedure/module.hpp"
 #include "query/stream/common.hpp"
 #include "utils/exceptions.hpp"
@@ -1188,13 +1189,26 @@ antlrcpp::Any CypherMainVisitor::visitCallProcedure(MemgraphCypher::CallProcedur
   const auto &maybe_found =
       procedure::FindProcedure(procedure::gModuleRegistry, call_proc->procedure_name_, utils::NewDeleteResource());
   if (!maybe_found) {
-    throw SemanticException("There is no procedure named '{}'.", call_proc->procedure_name_);
+    // TODO remove this once void procedures are supported,
+    // this will not be needed anymore.
+    const auto mg_specific_name = procedure::gCallableAliasMapper.FindAlias(call_proc->procedure_name_);
+    const bool void_procedure_required = (mg_specific_name && *mg_specific_name == "mgps.validate");
+    if (void_procedure_required) {
+      // This is a special case. Since void procedures currently are not supported,
+      // we have to make sure that the non-memgraph native, void procedures that are
+      // possibly used against a memgraph instance are handled correctly. As of now
+      // this is the only known such case. This should be more generic, but the most
+      // generic solution would be to implement void procedures.
+      call_proc->void_procedure_ = true;
+    } else {
+      throw SemanticException("There is no procedure named '{}'.", call_proc->procedure_name_);
+    }
   }
   call_proc->is_write_ = maybe_found->second->info.is_write;
 
   auto *yield_ctx = ctx->yieldProcedureResults();
   if (!yield_ctx) {
-    if (!maybe_found->second->results.empty()) {
+    if (!maybe_found->second->results.empty() && !call_proc->void_procedure_) {
       throw SemanticException(
           "CALL without YIELD may only be used on procedures which do not "
           "return any result fields.");
diff --git a/src/query/frontend/opencypher/grammar/Cypher.g4 b/src/query/frontend/opencypher/grammar/Cypher.g4
index 04dcc1499..ea9a9f5f7 100644
--- a/src/query/frontend/opencypher/grammar/Cypher.g4
+++ b/src/query/frontend/opencypher/grammar/Cypher.g4
@@ -407,4 +407,5 @@ cypherKeyword : ALL
 symbolicName : UnescapedSymbolicName
              | EscapedSymbolicName
              | cypherKeyword
+             | UNDERSCORE
              ;
diff --git a/src/query/frontend/opencypher/grammar/CypherLexer.g4 b/src/query/frontend/opencypher/grammar/CypherLexer.g4
index abf9aee13..3428a2191 100644
--- a/src/query/frontend/opencypher/grammar/CypherLexer.g4
+++ b/src/query/frontend/opencypher/grammar/CypherLexer.g4
@@ -179,6 +179,7 @@ EscapedSymbolicName   : ( '`' ~[`]* '`' )+ ;
  */
 IdentifierStart : ID_Start    | Pc ;
 IdentifierPart  : ID_Continue | Sc ;
+UNDERSCORE : '_' ;
 
 /* Hack for case-insensitive reserved words */
 fragment A : 'A' | 'a' ;
diff --git a/src/query/frontend/opencypher/grammar/MemgraphCypherLexer.g4 b/src/query/frontend/opencypher/grammar/MemgraphCypherLexer.g4
index 149e637b1..1f07e74f0 100644
--- a/src/query/frontend/opencypher/grammar/MemgraphCypherLexer.g4
+++ b/src/query/frontend/opencypher/grammar/MemgraphCypherLexer.g4
@@ -23,8 +23,6 @@ lexer grammar MemgraphCypherLexer ;
 
 import CypherLexer ;
 
-UNDERSCORE : '_' ;
-
 AFTER                   : A F T E R ;
 ALTER                   : A L T E R ;
 ANALYZE                 : A N A L Y Z E ;
diff --git a/src/query/interpret/awesome_memgraph_functions.cpp b/src/query/interpret/awesome_memgraph_functions.cpp
index 4640e0d8e..a6d886983 100644
--- a/src/query/interpret/awesome_memgraph_functions.cpp
+++ b/src/query/interpret/awesome_memgraph_functions.cpp
@@ -28,6 +28,7 @@
 #include "query/typed_value.hpp"
 #include "utils/string.hpp"
 #include "utils/temporal.hpp"
+#include "utils/uuid.hpp"
 
 namespace memgraph::query {
 namespace {
@@ -419,6 +420,10 @@ TypedValue Properties(const TypedValue *args, int64_t nargs, const FunctionConte
   }
 }
 
+TypedValue RandomUuid(const TypedValue * /*args*/, int64_t /*nargs*/, const FunctionContext &ctx) {
+  return TypedValue(utils::GenerateUUID(), ctx.memory);
+}
+
 TypedValue Size(const TypedValue *args, int64_t nargs, const FunctionContext &ctx) {
   FType<Or<Null, List, String, Map, Path>>("size", args, nargs);
   const auto &value = args[0];
@@ -1258,6 +1263,7 @@ std::function<TypedValue(const TypedValue *, int64_t, const FunctionContext &ctx
   if (function_name == kId) return Id;
   if (function_name == "LAST") return Last;
   if (function_name == "PROPERTIES") return Properties;
+  if (function_name == "RANDOMUUID") return RandomUuid;
   if (function_name == "SIZE") return Size;
   if (function_name == "STARTNODE") return StartNode;
   if (function_name == "TIMESTAMP") return Timestamp;
diff --git a/src/query/plan/operator.cpp b/src/query/plan/operator.cpp
index 26d7cc8fc..93bad291e 100644
--- a/src/query/plan/operator.cpp
+++ b/src/query/plan/operator.cpp
@@ -12,6 +12,7 @@
 #include "query/plan/operator.hpp"
 
 #include <algorithm>
+#include <cctype>
 #include <cstdint>
 #include <limits>
 #include <optional>
@@ -51,6 +52,7 @@
 #include "utils/event_counter.hpp"
 #include "utils/exceptions.hpp"
 #include "utils/fnv.hpp"
+#include "utils/java_string_formatter.hpp"
 #include "utils/likely.hpp"
 #include "utils/logging.hpp"
 #include "utils/memory.hpp"
@@ -4453,7 +4455,7 @@ UniqueCursorPtr OutputTableStream::MakeCursor(utils::MemoryResource *mem) const
 
 CallProcedure::CallProcedure(std::shared_ptr<LogicalOperator> input, std::string name, std::vector<Expression *> args,
                              std::vector<std::string> fields, std::vector<Symbol> symbols, Expression *memory_limit,
-                             size_t memory_scale, bool is_write)
+                             size_t memory_scale, bool is_write, bool void_procedure)
     : input_(input ? input : std::make_shared<Once>()),
       procedure_name_(name),
       arguments_(args),
@@ -4461,7 +4463,8 @@ CallProcedure::CallProcedure(std::shared_ptr<LogicalOperator> input, std::string
       result_symbols_(symbols),
       memory_limit_(memory_limit),
       memory_scale_(memory_scale),
-      is_write_(is_write) {}
+      is_write_(is_write),
+      void_procedure_(void_procedure) {}
 
 ACCEPT_WITH_INPUT(CallProcedure);
 
@@ -4697,10 +4700,68 @@ class CallProcedureCursor : public Cursor {
   }
 };
 
+class CallValidateProcedureCursor : public Cursor {
+  const CallProcedure *self_;
+  UniqueCursorPtr input_cursor_;
+
+ public:
+  CallValidateProcedureCursor(const CallProcedure *self, utils::MemoryResource *mem)
+      : self_(self), input_cursor_(self_->input_->MakeCursor(mem)) {}
+
+  bool Pull(Frame &frame, ExecutionContext &context) override {
+    SCOPED_PROFILE_OP("CallValidateProcedureCursor");
+
+    AbortCheck(context);
+    if (!input_cursor_->Pull(frame, context)) {
+      return false;
+    }
+
+    ExpressionEvaluator evaluator(&frame, context.symbol_table, context.evaluation_context, context.db_accessor,
+                                  storage::View::NEW);
+
+    const auto args = self_->arguments_;
+    MG_ASSERT(args.size() == 3U);
+
+    const auto predicate = args[0]->Accept(evaluator);
+    const bool predicate_val = predicate.ValueBool();
+
+    if (predicate_val) [[unlikely]] {
+      const auto &message = args[1]->Accept(evaluator);
+      const auto &message_args = args[2]->Accept(evaluator);
+
+      using TString = std::remove_cvref_t<decltype(message.ValueString())>;
+      using TElement = std::remove_cvref_t<decltype(message_args.ValueList()[0])>;
+
+      utils::JStringFormatter<TString, TElement> formatter;
+
+      try {
+        const auto &msg = formatter.FormatString(message.ValueString(), message_args.ValueList());
+        throw QueryRuntimeException(msg);
+      } catch (const utils::JStringFormatException &e) {
+        throw QueryRuntimeException(e.what());
+      }
+    }
+
+    return true;
+  }
+
+  void Reset() override { input_cursor_->Reset(); }
+
+  void Shutdown() override {}
+};
+
 UniqueCursorPtr CallProcedure::MakeCursor(utils::MemoryResource *mem) const {
   memgraph::metrics::IncrementCounter(memgraph::metrics::CallProcedureOperator);
   CallProcedure::IncrementCounter(procedure_name_);
 
+  if (void_procedure_) {
+    // Currently we do not support Call procedures that do not return
+    // anything. This cursor is way too specific, but it provides a workaround
+    // to ensure GraphQL compatibility until we start supporting truly void
+    // procedures.
+    return MakeUniqueCursorPtr<CallValidateProcedureCursor>(mem, this, mem);
+  }
+
   return MakeUniqueCursorPtr<CallProcedureCursor>(mem, this, mem);
 }
 
diff --git a/src/query/plan/operator.hpp b/src/query/plan/operator.hpp
index 79df74ffb..1ca3028c8 100644
--- a/src/query/plan/operator.hpp
+++ b/src/query/plan/operator.hpp
@@ -2178,7 +2178,7 @@ class CallProcedure : public memgraph::query::plan::LogicalOperator {
   CallProcedure() = default;
   CallProcedure(std::shared_ptr<LogicalOperator> input, std::string name, std::vector<Expression *> arguments,
                 std::vector<std::string> fields, std::vector<Symbol> symbols, Expression *memory_limit,
-                size_t memory_scale, bool is_write);
+                size_t memory_scale, bool is_write, bool void_procedure = false);
 
   bool Accept(HierarchicalLogicalOperatorVisitor &visitor) override;
   UniqueCursorPtr MakeCursor(utils::MemoryResource *) const override;
@@ -2200,6 +2200,7 @@ class CallProcedure : public memgraph::query::plan::LogicalOperator {
   Expression *memory_limit_{nullptr};
   size_t memory_scale_{1024U};
   bool is_write_;
+  bool void_procedure_;
   mutable utils::MonotonicBufferResource monotonic_memory{1024UL * 1024UL};
   utils::MemoryResource *memory_resource = &monotonic_memory;
 
@@ -2216,6 +2217,7 @@ class CallProcedure : public memgraph::query::plan::LogicalOperator {
     object->memory_limit_ = memory_limit_ ? memory_limit_->Clone(storage) : nullptr;
     object->memory_scale_ = memory_scale_;
     object->is_write_ = is_write_;
+    object->void_procedure_ = void_procedure_;
     return object;
   }
 
diff --git a/src/query/plan/rule_based_planner.hpp b/src/query/plan/rule_based_planner.hpp
index 09a53cf29..055d47d2c 100644
--- a/src/query/plan/rule_based_planner.hpp
+++ b/src/query/plan/rule_based_planner.hpp
@@ -221,7 +221,8 @@ class RuleBasedPlanner {
             // storage::View::NEW.
             input_op = std::make_unique<plan::CallProcedure>(
                 std::move(input_op), call_proc->procedure_name_, call_proc->arguments_, call_proc->result_fields_,
-                result_symbols, call_proc->memory_limit_, call_proc->memory_scale_, call_proc->is_write_);
+                result_symbols, call_proc->memory_limit_, call_proc->memory_scale_, call_proc->is_write_,
+                call_proc->void_procedure_);
           } else if (auto *load_csv = utils::Downcast<query::LoadCsv>(clause)) {
             const auto &row_sym = context.symbol_table->at(*load_csv->row_var_);
             context.bound_symbols.insert(row_sym);
diff --git a/src/query/procedure/callable_alias_mapper.cpp b/src/query/procedure/callable_alias_mapper.cpp
new file mode 100644
index 000000000..0440f0ea5
--- /dev/null
+++ b/src/query/procedure/callable_alias_mapper.cpp
@@ -0,0 +1,62 @@
+// Copyright 2023 Memgraph Ltd.
+//
+// Use of this software is governed by the Business Source License
+// included in the file licenses/BSL.txt; by using this file, you agree to be bound by the terms of the Business Source
+// License, and you may not use this file except in compliance with the Business Source License.
+//
+// As of the Change Date specified in that file, in accordance with
+// the Business Source License, use of this software will be governed
+// by the Apache License, Version 2.0, included in the file
+// licenses/APL.txt.
+
+#include "callable_alias_mapper.hpp"
+
+#include <algorithm>
+#include <array>
+#include <filesystem>
+#include <fstream>
+
+#include <spdlog/spdlog.h>
+#include <json/json.hpp>
+
+#include "utils/logging.hpp"
+
+namespace memgraph::query::procedure {
+
+// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
+CallableAliasMapper gCallableAliasMapper;
+
+void CallableAliasMapper::LoadMapping(const std::filesystem::path &path) {
+  using json = nlohmann::json;
+  if (path.empty()) {
+    spdlog::info("Path to callable mappings was not set.");
+    return;
+  }
+
+  if (std::filesystem::exists(path)) {
+    const bool is_regular_file = std::filesystem::is_regular_file(path);
+    const bool has_json_extension = (path.extension() == ".json");
+    if (is_regular_file && has_json_extension) {
+      std::ifstream mapping_file(path);
+      try {
+        json mapping_data = json::parse(mapping_file);
+        mapping_ = mapping_data.get<std::unordered_map<std::string, std::string>>();
+      } catch (...) {
+        MG_ASSERT(false, "Parsing callable mapping was unsuccesful. Make sure it is in correct json format.");
+      }
+    } else {
+      MG_ASSERT(false, "Path to callable mappings is not a regular file or does not have .json extension.");
+    }
+  } else {
+    MG_ASSERT(false, "Path to callable mappings was set, but the path does not exist.");
+  }
+}
+
+std::optional<std::string_view> CallableAliasMapper::FindAlias(const std::string &name) const noexcept {
+  if (!mapping_.contains(name)) {
+    return std::nullopt;
+  }
+  return mapping_.at(name);
+}
+
+}  // namespace memgraph::query::procedure
diff --git a/src/query/procedure/callable_alias_mapper.hpp b/src/query/procedure/callable_alias_mapper.hpp
new file mode 100644
index 000000000..83ee02729
--- /dev/null
+++ b/src/query/procedure/callable_alias_mapper.hpp
@@ -0,0 +1,45 @@
+// Copyright 2023 Memgraph Ltd.
+//
+// Use of this software is governed by the Business Source License
+// included in the file licenses/BSL.txt; by using this file, you agree to be bound by the terms of the Business Source
+// License, and you may not use this file except in compliance with the Business Source License.
+//
+// As of the Change Date specified in that file, in accordance with
+// the Business Source License, use of this software will be governed
+// by the Apache License, Version 2.0, included in the file
+// licenses/APL.txt.
+
+#pragma once
+
+#include <array>
+#include <cstring>
+#include <filesystem>
+#include <functional>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+
+namespace memgraph::query::procedure {
+
+class CallableAliasMapper final {
+ public:
+  CallableAliasMapper() = default;
+  CallableAliasMapper(const CallableAliasMapper &) = delete;
+  CallableAliasMapper &operator=(const CallableAliasMapper &) = delete;
+  CallableAliasMapper(CallableAliasMapper &&) = delete;
+  CallableAliasMapper &operator=(CallableAliasMapper &&) = delete;
+  ~CallableAliasMapper() = default;
+
+  void LoadMapping(const std::filesystem::path &);
+  [[nodiscard]] std::optional<std::string_view> FindAlias(const std::string &) const noexcept;
+
+ private:
+  std::unordered_map<std::string, std::string> mapping_;
+};
+
+/// Single, global alias mapper.
+// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
+extern CallableAliasMapper gCallableAliasMapper;
+
+}  // namespace memgraph::query::procedure
diff --git a/src/query/procedure/module.cpp b/src/query/procedure/module.cpp
index b7d0cb55c..895afad79 100644
--- a/src/query/procedure/module.cpp
+++ b/src/query/procedure/module.cpp
@@ -22,6 +22,7 @@ extern "C" {
 #include <unistd.h>
 
 #include "py/py.hpp"
+#include "query/procedure/callable_alias_mapper.hpp"
 #include "query/procedure/mg_procedure_helpers.hpp"
 #include "query/procedure/py_module.hpp"
 #include "utils/file.hpp"
@@ -1326,10 +1327,25 @@ std::optional<std::pair<ModulePtr, const T *>> MakePairIfPropFound(const ModuleR
     }
   };
   auto result = FindModuleNameAndProp(module_registry, fully_qualified_name, memory);
-  if (!result) return std::nullopt;
+  if (!result) {
+    return std::nullopt;
+  }
   auto [module_name, prop_name] = *result;
   auto module = module_registry.GetModuleNamed(module_name);
-  if (!module) return std::nullopt;
+  if (!module) {
+    // Check for possible callable aliases.
+    const auto maybe_valid_alias = gCallableAliasMapper.FindAlias(std::string(fully_qualified_name));
+    if (maybe_valid_alias) {
+      result = FindModuleNameAndProp(module_registry, *maybe_valid_alias, memory);
+      auto [module_name, prop_name] = *result;
+      module = module_registry.GetModuleNamed(module_name);
+      if (!module) {
+        return std::nullopt;
+      }
+    } else {
+      return std::nullopt;
+    }
+  }
   auto *prop = prop_fun(module);
   const auto &prop_it = prop->find(prop_name);
   if (prop_it == prop->end()) return std::nullopt;
diff --git a/src/utils/java_string_formatter.hpp b/src/utils/java_string_formatter.hpp
new file mode 100644
index 000000000..2b929715b
--- /dev/null
+++ b/src/utils/java_string_formatter.hpp
@@ -0,0 +1,101 @@
+// Copyright 2023 Memgraph Ltd.
+//
+// Use of this software is governed by the Business Source License
+// included in the file licenses/BSL.txt; by using this file, you agree to be bound by the terms of the Business Source
+// License, and you may not use this file except in compliance with the Business Source License.
+//
+// As of the Change Date specified in that file, in accordance with
+// the Business Source License, use of this software will be governed
+// by the Apache License, Version 2.0, included in the file
+// licenses/APL.txt.
+
+#pragma once
+
+#include <string>
+
+#include "utils/exceptions.hpp"
+#include "utils/pmr/string.hpp"
+#include "utils/pmr/vector.hpp"
+
+namespace memgraph::utils {
+
+class JStringFormatException final : public BasicException {
+ public:
+  explicit JStringFormatException(const std::string &what) noexcept : BasicException(what) {}
+
+  template <class... Args>
+  explicit JStringFormatException(fmt::format_string<Args...> fmt, Args &&...args) noexcept
+      : JStringFormatException(fmt::format(fmt, std::forward<Args>(args)...)) {}
+};
+
+template <typename T>
+concept TTypedValueLike = requires(T t) {
+  { t.ValueInt() } -> std::convertible_to<int>;
+  { t.ValueDouble() } -> std::convertible_to<double>;
+  { t.ValueString() } -> std::convertible_to<pmr::string>;
+};
+
+template <typename TString, typename TTypedValueLike>
+class JStringFormatter final {
+ public:
+  [[nodiscard]] TString FormatString(TString str, const pmr::vector<TTypedValueLike> &format_args) const {
+    std::size_t found{0U};
+    std::size_t arg_index{0U};
+
+    while (true) {
+      found = str.find('%', found);
+      if (found == std::string::npos) {
+        break;
+      }
+
+      const bool ends_with_percentile = (found == str.size() - 1U);
+      if (ends_with_percentile) {
+        break;
+      }
+
+      const auto format_specifier = str.at(found + 1U);
+      if (!std::isalpha(format_specifier)) {
+        ++found;
+        continue;
+      }
+      const bool does_argument_list_overflow = (format_args.size() < arg_index + 1U) && (arg_index > 0U);
+      if (does_argument_list_overflow) {
+        throw JStringFormatException(
+            "There are more format specifiers in the CALL procedure error message, then arguments provided.");
+      }
+      const bool arg_count_exceeds_format_spec_count = (arg_index > format_args.size() - 1U);
+      if (arg_count_exceeds_format_spec_count) {
+        break;
+      }
+
+      ReplaceFormatSpecifier(str, found, format_specifier, format_args.at(arg_index));
+      ++arg_index;
+      ++found;
+    }
+
+    str.shrink_to_fit();
+    return str;
+  }
+
+ private:
+  void ReplaceFormatSpecifier(TString &str, std::size_t pos, char format_specifier, TTypedValueLike current_arg) const {
+    std::string replacement_str;
+    switch (format_specifier) {
+      case 'd':
+        replacement_str = std::to_string(current_arg.ValueInt());
+        break;
+      case 'f':
+        replacement_str = std::to_string(current_arg.ValueDouble());
+        break;
+      case 's':
+        replacement_str = current_arg.ValueString();
+        break;
+      default:
+        throw JStringFormatException("Format specifier %'{}', in CALL procedure is not supported.", format_specifier);
+    }
+
+    str.replace(pos, 2U, replacement_str);
+  }
+};
+
+}  // namespace memgraph::utils
diff --git a/tests/e2e/CMakeLists.txt b/tests/e2e/CMakeLists.txt
index 6f729c968..188515b32 100644
--- a/tests/e2e/CMakeLists.txt
+++ b/tests/e2e/CMakeLists.txt
@@ -55,6 +55,7 @@ add_subdirectory(python_query_modules_reloading)
 add_subdirectory(analyze_graph)
 add_subdirectory(transaction_queue)
 add_subdirectory(mock_api)
+add_subdirectory(graphql)
 add_subdirectory(disk_storage)
 add_subdirectory(load_csv)
 add_subdirectory(init_file_flags)
diff --git a/tests/e2e/configuration/default_config.py b/tests/e2e/configuration/default_config.py
index d2c55ae1d..a0a7153fb 100644
--- a/tests/e2e/configuration/default_config.py
+++ b/tests/e2e/configuration/default_config.py
@@ -192,4 +192,9 @@ startup_config_dict = {
         "false",
         "Restore replication state on startup, e.g. recover replica",
     ),
+    "query_callable_mappings_path": (
+        "",
+        "",
+        "The path to mappings that describes aliases to callables in cypher queries in the form of key-value pairs in a json file. With this option query module procedures that do not exist in memgraph can be mapped to ones that exist.",
+    ),
 }
diff --git a/tests/e2e/graphql/.gitignore b/tests/e2e/graphql/.gitignore
new file mode 100644
index 000000000..da564214b
--- /dev/null
+++ b/tests/e2e/graphql/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+# Because the parent folder ignores *.json
+!callable_alias_mapping.json
+!package.json
diff --git a/tests/e2e/graphql/CMakeLists.txt b/tests/e2e/graphql/CMakeLists.txt
new file mode 100644
index 000000000..7ad1624b6
--- /dev/null
+++ b/tests/e2e/graphql/CMakeLists.txt
@@ -0,0 +1,10 @@
+function(copy_graphql_e2e_python_files FILE_NAME)
+    copy_e2e_python_files(graphql ${FILE_NAME})
+endfunction()
+
+copy_graphql_e2e_python_files(graphql_crud.py)
+copy_graphql_e2e_python_files(graphql_server.py)
+copy_graphql_e2e_python_files(callable_alias_mapping.json)
+
+add_subdirectory(graphql_library_config)
+add_subdirectory(temporary_procedures)
diff --git a/tests/e2e/graphql/callable_alias_mapping.json b/tests/e2e/graphql/callable_alias_mapping.json
new file mode 100644
index 000000000..cd86f8d7b
--- /dev/null
+++ b/tests/e2e/graphql/callable_alias_mapping.json
@@ -0,0 +1,4 @@
+{
+    "dbms.components": "mgps.components",
+    "apoc.util.validate": "mgps.validate"
+}
diff --git a/tests/e2e/graphql/graphql_crud.py b/tests/e2e/graphql/graphql_crud.py
new file mode 100644
index 000000000..fe9f28d76
--- /dev/null
+++ b/tests/e2e/graphql/graphql_crud.py
@@ -0,0 +1,105 @@
+import sys
+
+import pytest
+from graphql_server import *
+
+
+def test_create_query(query_server):
+    query = 'mutation{createUsers(input:[{name:"John Doe"}]){users{id name}}}'
+    gotten = query_server.send_query(query)
+    expected_result = (
+        '{"data":{"createUsers":{"users":[{"id":"e2d65187-d522-47bf-9791-6c66dd8fd672","name":"John Doe"}]}}}'
+    )
+    assert server_returned_expected(expected_result, gotten)
+
+
+def test_nested_create_query(query_server):
+    query = """
+    mutation {
+        createUsers(input: [
+            {
+                name: "John Doe"
+                posts: {
+                    create: [
+                        {
+                            node: {
+                                content: "Hi, my name is John!"
+                            }
+                        }
+                    ]
+                }
+            }
+        ]) {
+            users {
+                id
+                name
+                posts {
+                    id
+                    content
+                }
+            }
+        }
+    }
+    """
+
+    expected_result = '{"data":{"createUsers":{"users":[{"id": "361004b7-f92d-4df0-9f96-5b43602c0f25","name": "John Doe","posts":[{"id":"e8d2033f-c15e-4529-a4f8-ca2ae09a066b",       "content": "Hi, my name is John!"}]}]}}}'
+    gotten_response = query_server.send_query(query)
+    assert server_returned_expected(expected_result, gotten_response)
+
+
+def test_delete_node_query(query_server):
+    created_node_uuid = create_node_query(query_server)
+
+    delete_query = 'mutation{deleteUsers(where:{id:"' + created_node_uuid + '"}){nodesDeleted relationshipsDeleted}}'
+    expected_delete_response = '{"data":{"deleteUsers":{"nodesDeleted":1,"relationshipsDeleted":0}}}\n'
+
+    gotten = query_server.send_query(delete_query)
+    assert expected_delete_response == str(gotten.text)
+
+
+def test_nested_delete_node_query(query_server):
+    node_uuids = create_related_nodes_query(query_server)
+    created_user_uuid = node_uuids[0]
+
+    delete_query = (
+        'mutation {deleteUsers(where: {id: "'
+        + created_user_uuid
+        + '"},delete: {posts: {where: {}}}) {nodesDeleted relationshipsDeleted}}'
+    )
+    expected_delete_response = '{"data":{"deleteUsers":{"nodesDeleted":2,"relationshipsDeleted":1}}}\n'
+
+    gotten = query_server.send_query(delete_query)
+    assert expected_delete_response == str(gotten.text)
+
+
+def test_update_node(query_server):
+    node_uuids = create_related_nodes_query(query_server)
+    created_post_uuid = node_uuids[1]
+
+    update_query = (
+        'mutation {updatePosts(where: {id: "'
+        + created_post_uuid
+        + '"}update: {content: "Some new content for this Post!"}) {posts {content}}}'
+    )
+    expected_update_response = '{"data":{"updatePosts":{"posts":[{"content":"Some new content for this Post!"}]}}}\n'
+
+    gotten = query_server.send_query(update_query)
+    assert expected_update_response == str(gotten.text)
+
+
+def test_connect_or_create(query_server):
+    created_user_uuid = create_node_query(query_server)
+
+    connect_or_create_query = (
+        'mutation {updateUsers(update: {posts: {connectOrCreate: {where: { node: { id: "1234" } }onCreate: { node: { content: "Some content" } }}}},where: { id: "'
+        + created_user_uuid
+        + '" }) {info {nodesCreated}}}'
+    )
+    expected_response = '{"data":{"updateUsers":{"info":{"nodesCreated":1}}}}\n'
+
+    gotten = query_server.send_query(connect_or_create_query)
+    assert expected_response == str(gotten.text)
+
+
+if __name__ == "__main__":
+    sys.exit(pytest.main([__file__, "-rA"]))
diff --git a/tests/e2e/graphql/graphql_library_config/CMakeLists.txt b/tests/e2e/graphql/graphql_library_config/CMakeLists.txt
new file mode 100644
index 000000000..860c4f240
--- /dev/null
+++ b/tests/e2e/graphql/graphql_library_config/CMakeLists.txt
@@ -0,0 +1 @@
+copy_graphql_e2e_python_files(crud.js)
diff --git a/tests/e2e/graphql/graphql_library_config/crud.js b/tests/e2e/graphql/graphql_library_config/crud.js
new file mode 100644
index 000000000..65c65c79f
--- /dev/null
+++ b/tests/e2e/graphql/graphql_library_config/crud.js
@@ -0,0 +1,34 @@
+const { Neo4jGraphQL } = require("@neo4j/graphql");
+const { ApolloServer, gql } = require("apollo-server");
+const neo4j = require("neo4j-driver");
+
+const typeDefs = gql`
+type Post {
+    id: ID! @id
+    content: String!
+    creator: User! @relationship(type: "HAS_POST", direction: IN)
+}
+
+type User {
+    id: ID! @id
+    name: String
+    posts: [Post!]! @relationship(type: "HAS_POST", direction: OUT)
+}
+`;
+
+const driver = neo4j.driver(
+    "bolt://localhost:7687",
+    neo4j.auth.basic("", "")
+);
+
+const neoSchema = new Neo4jGraphQL({ typeDefs, driver });
+
+neoSchema.getSchema().then((schema) => {
+    const server = new ApolloServer({
+        schema,
+    });
+
+    server.listen().then(({ url }) => {
+        console.log(`🚀 Server ready at ${url}`);
+    });
+})
diff --git a/tests/e2e/graphql/graphql_server.py b/tests/e2e/graphql/graphql_server.py
new file mode 100644
index 000000000..74a5e197a
--- /dev/null
+++ b/tests/e2e/graphql/graphql_server.py
@@ -0,0 +1,149 @@
+import atexit
+import collections.abc
+import json
+import os.path
+import socket
+import subprocess
+import time
+from uuid import UUID
+
+import pytest
+
+import requests
+
+
+class GraphQLServer:
+    def __init__(self, config_file_path: str):
+        self.url = "http://127.0.0.1:4000"
+
+        self.graphql_lib = subprocess.Popen(["node", config_file_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+
+        self.__wait_process_to_init(7687)
+        self.__wait_process_to_init(4000)
+        atexit.register(self.__shut_down)
+
+    def send_query(self, query: str, timeout=5.0) -> requests.Response:
+        try:
+            response = requests.post(self.url, json={"query": query}, timeout=timeout)
+        except requests.exceptions.Timeout as err:
+            print("Request to GraphQL server has timed out. Details:", err)
+        else:
+            return response
+
+    def __wait_process_to_init(self, port):
+        host = "127.0.0.1"
+        try:
+            while True:
+                # Create a socket object
+                with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+                    s.settimeout(5)
+                    result = s.connect_ex((host, port))
+                    if result == 0:
+                        break
+
+        except socket.error as e:
+            print(f"Error occurred while checking port {port}: {e}")
+            return False
+
+    def __shut_down(self):
+        self.graphql_lib.kill()
+        ls = subprocess.Popen(("lsof", "-t", "-i:4000"), stdout=subprocess.PIPE)
+        subprocess.check_output(("xargs", "-r", "kill"), stdin=ls.stdout)
+        ls.wait()
+
+
+def _ordered(obj: any) -> any:
+    if isinstance(obj, dict):
+        return sorted((k, _ordered(v)) for k, v in obj.items())
+    if isinstance(obj, list):
+        return sorted(_ordered(x) for x in obj)
+    else:
+        return obj
+
+
+def _flatten(x: any) -> list:
+    result = []
+    for el in x:
+        if isinstance(x, collections.abc.Iterable) and not isinstance(el, str):
+            result.extend(_flatten(el))
+        else:
+            result.append(el)
+    return result
+
+
+def _valid_uuid(uuid_to_test: any, version: int = 4) -> any:
+    try:
+        uuid_obj = UUID(uuid_to_test, version=version)
+    except ValueError:
+        return False
+    return str(uuid_obj) == uuid_to_test
+
+
+def server_returned_expected(expected_string: str, server_response: requests.Response) -> bool:
+    expected_json = json.loads(expected_string)
+    server_response_json = json.loads(server_response.text)
+
+    expected = _flatten(_ordered(expected_json))
+    actual = _flatten(_ordered(server_response_json))
+
+    for expected_item, actual_item in zip(expected, actual):
+        if expected_item != actual_item and not (_valid_uuid(expected_item)):
+            return False
+
+    return True
+
+
+def get_uuid_from_response(response: requests.Response) -> list:
+    response_json = json.loads(response.text)
+    flattened_response = _flatten(_ordered(response_json))
+    uuids = []
+    for item in flattened_response:
+        if _valid_uuid(item):
+            uuids.append(str(item))
+    return uuids
+
+
+def create_node_query(server: GraphQLServer):
+    query = 'mutation{createUsers(input:[{name:"John Doe"}]){users{id name}}}'
+    gotten = server.send_query(query)
+    uuids = get_uuid_from_response(gotten)
+    return uuids[0]
+
+
+def create_related_nodes_query(server: GraphQLServer):
+    query = """
+        mutation {
+            createUsers(input: [
+                {
+                    name: "John Doe"
+                    posts: {
+                        create: [
+                            {
+                                node: {
+                                    content: "Hi, my name is John!"
+                                }
+                            }
+                        ]
+                    }
+                }
+            ]) {
+                users {
+                    id
+                    name
+                    posts {
+                        id
+                        content
+                    }
+                }
+            }
+        }
+    """
+
+    gotten_response = server.send_query(query)
+    return get_uuid_from_response(gotten_response)
+
+
+@pytest.fixture
+def query_server() -> GraphQLServer:
+    path = os.path.join("graphql/graphql_library_config/crud.js")
+    return GraphQLServer(path)
diff --git a/tests/e2e/graphql/package.json b/tests/e2e/graphql/package.json
new file mode 100644
index 000000000..175e9f4a2
--- /dev/null
+++ b/tests/e2e/graphql/package.json
@@ -0,0 +1,9 @@
+{
+  "dependencies": {
+    "@apollo/server": "^4.8.1",
+    "@neo4j/graphql": "^3.24.0",
+    "apollo-server": "^3.12.0",
+    "graphql": "^16.7.1",
+    "neo4j-driver": "^5.10.0"
+  }
+}
diff --git a/tests/e2e/graphql/setup.sh b/tests/e2e/graphql/setup.sh
new file mode 100755
index 000000000..87d57e64c
--- /dev/null
+++ b/tests/e2e/graphql/setup.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+set -Eeuo pipefail
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$SCRIPT_DIR"
+
+source "$SCRIPT_DIR/../../util.sh"
+setup_node
+
+npm i
diff --git a/tests/e2e/graphql/temporary_procedures/CMakeLists.txt b/tests/e2e/graphql/temporary_procedures/CMakeLists.txt
new file mode 100644
index 000000000..1be99754e
--- /dev/null
+++ b/tests/e2e/graphql/temporary_procedures/CMakeLists.txt
@@ -0,0 +1 @@
+copy_graphql_e2e_python_files(mgps.py)
diff --git a/tests/e2e/graphql/temporary_procedures/mgps.py b/tests/e2e/graphql/temporary_procedures/mgps.py
new file mode 100644
index 000000000..5732b46e7
--- /dev/null
+++ b/tests/e2e/graphql/temporary_procedures/mgps.py
@@ -0,0 +1,8 @@
+import typing
+
+import mgp
+
+
+@mgp.read_proc
+def components(context: mgp.ProcCtx) -> mgp.Record(versions=list, edition=str):
+    return mgp.Record(versions=["4.3"], edition="4.3.2")
diff --git a/tests/e2e/graphql/workloads.yaml b/tests/e2e/graphql/workloads.yaml
new file mode 100644
index 000000000..2122e2195
--- /dev/null
+++ b/tests/e2e/graphql/workloads.yaml
@@ -0,0 +1,35 @@
+args: &args
+ - "--bolt-port"
+ - "7687"
+ - "--log-level"
+ - "TRACE"
+ - "--query-callable-mappings-path"
+ - "graphql/callable_alias_mapping.json"
+
+in_memory_cluster: &in_memory_cluster
+  cluster:
+    main:
+      args: *args
+      log_file: "graphql-e2e.log"
+      setup_queries: []
+      validation_queries: []
+
+disk_cluster: &disk_cluster
+  cluster:
+    main:
+      args: *args
+      log_file: "graphql-e2e.log"
+      setup_queries: ["STORAGE MODE ON_DISK_TRANSACTIONAL"]
+      validation_queries: []
+
+workloads:
+  - name: "GraphQL crud"
+    binary: "tests/e2e/pytest_runner.sh"
+    proc: "tests/e2e/graphql/temporary_procedures/"
+    args: ["graphql/graphql_crud.py"]
+    <<: *in_memory_cluster
+  - name: "Disk GraphQL crud"
+    binary: "tests/e2e/pytest_runner.sh"
+    proc: "tests/e2e/graphql/temporary_procedures/"
+    args: ["graphql/graphql_crud.py"]
+    <<: *disk_cluster
diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh
index c3611b2bf..e6a2ec1dc 100755
--- a/tests/e2e/run.sh
+++ b/tests/e2e/run.sh
@@ -1,4 +1,6 @@
 #!/bin/bash
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$SCRIPT_DIR"
 # TODO(gitbuda): Setup mgclient and pymgclient properly.
 export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:../../libs/mgclient/lib
 
@@ -17,6 +19,9 @@ check_license() {
   fi
 }
 
+source "$SCRIPT_DIR/../util.sh"
+setup_node
+
 if [ "$#" -eq 0 ]; then
   check_license
   # NOTE: If you want to run all tests under specific folder/section just
diff --git a/tests/setup.sh b/tests/setup.sh
index 1630a6454..9c1e7d20c 100755
--- a/tests/setup.sh
+++ b/tests/setup.sh
@@ -1,10 +1,10 @@
 #!/bin/bash
-
 # shellcheck disable=1091
 
 set -Eeuo pipefail
-
 DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$DIR"
+
 PIP_DEPS=(
    "behave==1.2.6"
    "ldap3==2.6"
@@ -19,14 +19,11 @@ PIP_DEPS=(
    "networkx==2.4"
    "gqlalchemy==1.3.3"
 )
-cd "$DIR"
 
-# Remove old virtualenv.
+# Remove old and create a new virtualenv.
 if [ -d ve3 ]; then
     rm -rf ve3
 fi
-
-# Create new virtualenv.
 virtualenv -p python3 ve3
 set +u
 source "ve3/bin/activate"
@@ -37,7 +34,6 @@ PYTHON_MINOR=$(python3 -c 'import sys; print(sys.version_info[:][1])')
 
 # install pulsar-client
 pip --timeout 1000 install "pulsar-client==3.1.0"
-
 for pkg in "${PIP_DEPS[@]}"; do
     pip --timeout 1000 install "$pkg"
 done
@@ -51,3 +47,5 @@ CFLAGS="-std=c99" python3 setup.py install
 popd > /dev/null
 
 deactivate
+
+"$DIR"/e2e/graphql/setup.sh
diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt
index 1c69d2606..da6987260 100644
--- a/tests/unit/CMakeLists.txt
+++ b/tests/unit/CMakeLists.txt
@@ -287,6 +287,9 @@ target_link_libraries(${test_prefix}utils_settings mg-utils mg-settings)
 add_unit_test(utils_temporal utils_temporal.cpp)
 target_link_libraries(${test_prefix}utils_temporal mg-utils)
 
+add_unit_test(utils_java_string_formatter.cpp)
+target_link_libraries(${test_prefix}utils_java_string_formatter mg-utils)
+
 # Test mg-storage-v2
 add_unit_test(commit_log_v2.cpp)
 target_link_libraries(${test_prefix}commit_log_v2 gflags mg-utils mg-storage-v2)
diff --git a/tests/unit/utils_java_string_formatter.cpp b/tests/unit/utils_java_string_formatter.cpp
new file mode 100644
index 000000000..94c0392ef
--- /dev/null
+++ b/tests/unit/utils_java_string_formatter.cpp
@@ -0,0 +1,221 @@
+// Copyright 2023 Memgraph Ltd.
+//
+// Use of this software is governed by the Business Source License
+// included in the file licenses/BSL.txt; by using this file, you agree to be bound by the terms of the Business Source
+// License, and you may not use this file except in compliance with the Business Source License.
+//
+// As of the Change Date specified in that file, in accordance with
+// the Business Source License, use of this software will be governed
+// by the Apache License, Version 2.0, included in the file
+// licenses/APL.txt.
+
+#include <iostream>
+#include <string_view>
+#include <variant>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+#include <utils/java_string_formatter.hpp>
+#include <utils/pmr/string.hpp>
+
+using TString = memgraph::utils::pmr::string;
+
+namespace {
+
+class DummyTypedValue {
+ public:
+  template <typename T>
+  DummyTypedValue(T &&val) : data_(std::forward<decltype(val)>(val)) {}
+
+  int ValueInt() const noexcept {
+    auto *value = std::get_if<int>(&data_);
+    MG_ASSERT(value);
+    return *value;
+  }
+  double ValueDouble() const noexcept {
+    auto *value = std::get_if<double>(&data_);
+    MG_ASSERT(value);
+    return *value;
+  }
+  TString ValueString() const noexcept {
+    auto *value = std::get_if<TString>(&data_);
+    MG_ASSERT(value);
+    return *value;
+  }
+
+ private:
+  std::variant<int, double, TString> data_;
+};
+
+auto GetVector() { return memgraph::utils::pmr::vector<DummyTypedValue>(memgraph::utils::NewDeleteResource()); }
+auto GetString() { return TString(memgraph::utils::NewDeleteResource()); }
+
+memgraph::utils::JStringFormatter<TString, DummyTypedValue> gFormatter;
+
+}  // namespace
+
+TEST(JavaStringFormatter, FormatesOneIntegerCharacter) {
+  auto args = GetVector();
+  auto str = GetString();
+  args.emplace_back(1);
+  str = "\%d";
+
+  auto result = gFormatter.FormatString(str, args);
+
+  ASSERT_EQ(result, "1");
+}
+
+TEST(JavaStringFormatter, FormatesOneDoubleCharacter) {
+  auto args = GetVector();
+  auto str = GetString();
+  args.emplace_back(1.0);
+  str = "\%f";
+
+  auto result = gFormatter.FormatString(str, args);
+
+  ASSERT_EQ(result, "1.000000");
+}
+
+TEST(JavaStringFormatter, FormatesOneSimpleStringCharacter) {
+  auto args = GetVector();
+  auto str = GetString();
+  auto replacement_str = GetString();
+  replacement_str = "x";
+  args.emplace_back(replacement_str);
+  str = "\%s";
+
+  auto result = gFormatter.FormatString(str, args);
+
+  ASSERT_EQ(result, "x");
+}
+
+TEST(JavaStringFormatter, FormatesOneComplexStringCharacter) {
+  auto args = GetVector();
+  auto str = GetString();
+  auto replacement_str = GetString();
+  replacement_str = "moja najdraža boja je zelena";
+  args.emplace_back(replacement_str);
+  str = "\%s";
+
+  auto result = gFormatter.FormatString(str, args);
+
+  ASSERT_EQ(result, "moja najdraža boja je zelena");
+}
+
+TEST(JavaStringFormatter, FormatesMoreCharacters) {
+  auto args = GetVector();
+  auto str = GetString();
+  args.emplace_back(1);
+  args.emplace_back(2);
+  args.emplace_back(3);
+  str = "\%d\%d\%d";
+
+  auto result = gFormatter.FormatString(str, args);
+
+  ASSERT_EQ(result, "123");
+}
+
+TEST(JavaStringFormatter, FormatesMoreCharactersInSentence) {
+  auto args = GetVector();
+  auto str = GetString();
+  args.emplace_back(1);
+  args.emplace_back(10);
+  args.emplace_back(10.0);
+  str = "The chances of picking \%d out of \%d matches is \%f%";
+
+  auto result = gFormatter.FormatString(str, args);
+
+  ASSERT_EQ(result, "The chances of picking 1 out of 10 matches is 10.000000%");
+}
+
+TEST(JavaStringFormatter, FormateSinglePecrentile) {
+  auto args = GetVector();
+  auto str = GetString();
+  str = "%";
+
+  auto result = gFormatter.FormatString(str, args);
+
+  ASSERT_EQ(result, "%");
+}
+
+TEST(JavaStringFormatter, FormatPercineleBeforeFormatString) {
+  auto args = GetVector();
+  auto str = GetString();
+  args.emplace_back(1);
+  str = "%\%d";
+
+  auto result = gFormatter.FormatString(str, args);
+
+  ASSERT_EQ(result, "\%1");
+}
+
+TEST(JavaStringFormatter, FormatPercineleAfterFormatString) {
+  auto args = GetVector();
+  auto str = GetString();
+  args.emplace_back(1);
+  str = "\%d%";
+
+  auto result = gFormatter.FormatString(str, args);
+
+  ASSERT_EQ(result, "1\%");
+}
+
+TEST(JavaStringFormatter, FormatPercineleInBetweenFormatStrings) {
+  auto args = GetVector();
+  auto str = GetString();
+  args.emplace_back(1);
+  str = "%\%d%";
+
+  auto result = gFormatter.FormatString(str, args);
+
+  ASSERT_EQ(result, "\%1\%");
+}
+
+TEST(JavaStringFormatter, FormatManyPercentilesOneFormatString) {
+  auto args = GetVector();
+  auto str = GetString();
+  args.emplace_back(1);
+  str = "Some% random% strings here% and there wit% h \%da bunch of percent% iles and one format%-string.";
+
+  auto result = gFormatter.FormatString(str, args);
+
+  ASSERT_EQ(result, "Some% random% strings here% and there wit% h 1a bunch of percent% iles and one format%-string.");
+}
+
+TEST(JavaStringFormatter, ThrowOnNonExistentFormatSpecifier) {
+  auto args = GetVector();
+  auto str = GetString();
+  args.emplace_back(1);
+  str = "\%x";
+
+  ASSERT_THROW(gFormatter.FormatString(str, args), memgraph::utils::JStringFormatException);
+}
+
+TEST(JavaStringFormatter, ThrowOnLessFormatSpecifiersThanSlots) {
+  auto args = GetVector();
+  auto str = GetString();
+  auto replacement_str = GetString();
+  replacement_str = "format specifiers";
+  args.emplace_back(3);
+  args.emplace_back(replacement_str);
+  str = "There is \%d \%s in this sentence but only \%d specified in the argument list";
+
+  ASSERT_THROW(gFormatter.FormatString(str, args), memgraph::utils::JStringFormatException);
+}
+
+TEST(JavaStringFormatter, DoNotThrowOnMoreFormatSpecifiersThanSlots) {
+  auto args = GetVector();
+  auto str = GetString();
+  auto replacement_str = GetString();
+  replacement_str = "format specifiers";
+  args.emplace_back(3);
+  args.emplace_back(replacement_str);
+  args.emplace_back(123);
+  args.emplace_back(123);
+  str = "There is \%d \%s in this sentence but only \%d specified in the argument list";
+
+  auto result = gFormatter.FormatString(str, args);
+
+  ASSERT_EQ(result, "There is 3 format specifiers in this sentence but only 123 specified in the argument list");
+}
diff --git a/tests/util.sh b/tests/util.sh
new file mode 100644
index 000000000..450ae9046
--- /dev/null
+++ b/tests/util.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+setup_node() {
+  if [ -f "$HOME/.nvm/nvm.sh" ]; then
+    . "$HOME/.nvm/nvm.sh"
+    nvm install 14
+    nvm use 14
+  fi
+
+  if ! command -v node >/dev/null; then
+    echo "Could NOT node. Make sure node is installed."
+    exit 1
+  fi
+  if ! command -v npm >/dev/null; then
+    echo "Could NOT npm. Make sure npm is installed."
+    exit 1
+  fi
+  node_version=$(node --version)
+  npm_version=$(npm --version)
+  echo "NODE VERSION: $node_version"
+  echo "NPM  VERSION: $npm_version"
+  node_major_version=${node_version##v}
+  node_major_version=${node_major_version%%.*}
+  if [ ! "$node_major_version" -ge 14 ]; then
+    echo "ERROR: It's required to have node >= 14."
+    exit 1
+  fi
+}