How to Mock SAP OData Services in Your CI/CD Pipeline

Ovidiu10 min read

The Metadata Nobody Uses

Every SAP OData service exposes a $metadata endpoint. It returns an EDMX document — XML that describes every entity type, every property, every navigation relationship, and every data type in the service.

API_BUSINESS_PARTNER alone defines 65 entity types. $metadata describes all of them: the data types (Edm.String, Edm.DateTime, Edm.Decimal), the nullable flags, the key fields, the navigation paths. It's a complete, machine-readable contract sitting at a well-known URL.

Most mocking strategies for SAP OData ignore this entirely. They ask developers to hand-write the same information that SAP already provides.

Why SAP OData Is Harder to Mock Than REST

Before comparing approaches, here's what makes SAP OData structurally different from a typical JSON API. These are the details that trip up generic mock tools.

Protocol envelopes. OData V2 wraps entity collections in {"d": {"results": [...]}} with __metadata objects on each entity containing URIs and type names. OData V4 uses {"value": [...]} with @odata.context annotations.

A mock that returns a plain JSON array will break client libraries like SAP Cloud SDK or generated OData client proxies. They expect the protocol envelope.

Date encoding. SAP OData V2 serializes dates as /Date(1672531200000)/ — milliseconds since epoch inside a string wrapper. Not ISO 8601. If your mock returns "2024-01-01", deserialization will either silently produce a wrong value or throw, depending on your OData library.

Navigation properties. When your code calls $expand=to_BusinessPartnerAddress, the response must include nested entity collections inside each parent entity, each with their own __metadata and correct structure.

Deeper expansions like $expand=to_BusinessPartnerAddress/to_EmailAddress multiply the complexity at each level. Real-world SAP queries routinely expand two or three levels deep.

CSRF tokens. SAP requires a x-csrf-token: fetch handshake before any write operation. Your mock server needs to participate in this protocol or the client will fail before sending the actual request.

Entity naming mismatch. The URL uses A_BusinessPartner (the EntitySet), but __metadata.type says API_BUSINESS_PARTNER.A_BusinessPartnerType (the EntityType). These are defined separately in EDMX — EntitySet elements in the EntityContainer reference EntityType definitions in the schema. Get this wrong and type-checking client code breaks.

A mock server that returns static JSON handles none of this automatically. Here are five ways teams deal with it, and what actually works.

Approach 1: Test Against the Real SAP System

The default. Configure your test environment to point at a shared SAP system and run tests directly.

This works until:

  • The VPN drops and your pipeline fails at 3am
  • Another team mutates your test data mid-run
  • SAP goes offline for a support package update
  • You need parallel CI runs and SAP can't handle the concurrency
  • A developer working remotely doesn't have VPN access

On teams that share a SAP test system, it's common to lose several hours a week to pipeline failures caused by SAP unavailability or data inconsistency. Not dramatic crashes — the test that passed yesterday fails today because someone changed a business partner's category in the shared client.

Setup time: Zero. Maintenance: Zero. Reliability in CI/CD: Poor.

Approach 2: Hand-Coded JSON Fixtures

The most common workaround. Record a real response from SAP, save it as a JSON file, serve it in tests.

{
  "d": {
    "results": [
      {
        "__metadata": {
          "uri": "https://myhost/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner('1000001')",
          "type": "API_BUSINESS_PARTNER.A_BusinessPartnerType"
        },
        "BusinessPartner": "1000001",
        "BusinessPartnerFullName": "Acme Corp",
        "BusinessPartnerCategory": "2",
        "CreationDate": "/Date(1672531200000)/",
        "to_BusinessPartnerAddress": { "results": [] }
      }
    ]
  }
}

Deterministic. Offline. Fast. Also fragile:

  • Schema drift. SAP adds a new property in a support package. Your fixture doesn't have it. Deserialization succeeds, the property defaults to null, and your code fails in production when it encounters a field it never saw in tests.
  • Shallow coverage. You wrote fixtures for A_BusinessPartner and A_BusinessPartnerAddress. Your service integrates with 12 entity types. The other 10 have no test coverage.
  • Expanding complexity. Every new $expand path needs a new fixture file with manually nested entities. $expand=to_BusinessPartnerAddress,to_BusinessPartnerContact doubles the JSON you maintain.

Setup time: 1-2 days per SAP service. Maintenance: A few hours per schema change to update fixture files. Teams often stop maintaining them after the first quarter.

Approach 3: WireMock or MockServer

A running HTTP server with request matching and response templates. Better than static files — you get URL path matching, query parameter handling, and response templating.

For SAP OData, a WireMock mapping looks like this:

{
  "request": {
    "method": "GET",
    "urlPathPattern": "/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner.*"
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "bodyFileName": "business-partners.json"
  }
}

You still author every response body by hand. For a medium-complexity SAP service:

  • 20+ entity types = 20+ mapping files plus 20+ response bodies
  • Dozens of properties per type = hundreds of lines of handcrafted JSON per entity
  • Navigation properties = nested JSON that grows with each $expand depth level
  • Query variations = separate mappings or Handlebars templating for $top, $filter, $inlinecount

WireMock has no concept of EDMX. It doesn't know that Edm.DateTime should format as /Date(...)/ or that __metadata.type should be API_BUSINESS_PARTNER.A_BusinessPartnerType. You're encoding SAP protocol details by hand in every response file.

MockServer has the same limitation — it's a general-purpose HTTP mock with no OData awareness.

For a detailed comparison of WireMock and metadata-driven mocking for SAP OData, see MockLayer vs WireMock for SAP OData Testing.

Setup time: 2-5 days for one SAP service. Maintenance: Every schema change means updating response files. In practice, teams mock 2-3 entity types and leave the rest untested.

Approach 4: Build Your Own Mock Server

Some teams decide to automate the generation: parse the EDMX, generate responses programmatically. This is the right instinct. But the EDMX specification is deeper than it looks:

  • Entity type inheritance and abstract types
  • Complex types nested within entity types
  • Associations, AssociationSets, and ReferentialConstraints (V2) vs NavigationPropertyBinding (V4)
  • Enum types with string or integer backing
  • Annotations that affect serialization behavior

Teams that go this route typically spend 4-8 weeks on the initial implementation and end up covering a limited subset of the EDMX spec. The gaps surface as edge cases over the following months — an entity type with inheritance, a property with an unexpected annotation, a navigation property chain that doesn't resolve.

Realistic data generation adds another layer of complexity. A field named EmailAddress should contain an email, not a random string. PostalCode should look like a postal code. Without property-name heuristics, the generated data is technically type-correct but useless for visual testing or debugging.

The fundamental risk: you're now maintaining two products — your application and its mock server.

Setup time: 4-8 weeks. Maintenance: 1-2 days per month for edge cases and schema changes.

Approach 5: Metadata-Driven Mock Generation

This is the approach behind MockLayer. It starts from one observation: SAP already describes its API structure completely. The $metadata endpoint returns everything needed to generate valid responses — entity types, property definitions, data types, navigation relationships, key fields.

A mock server that reads this document can generate structurally correct responses for any entity in any service, without hand-written stubs.

MockLayer is a standalone HTTP proxy that:

  1. Fetches $metadata from your SAP system and caches it across three tiers — memory, disk, and the original SAP source. After the configurable TTL expires (default 24 hours), the next request re-validates against SAP. If SAP is unreachable at that point, stale cache is served as a fallback. The disk cache also survives process restarts, so SAP only needs to be reachable once per service.
  2. Parses the EDMX to extract all entity schemas — entity types, navigation properties, complex types, and associations.
  3. Generates type-correct mock data using property-name heuristics — fields named *Email* get email addresses, *Price* gets currency values, *Date* gets SAP-formatted dates in the correct /Date(...)/ or ISO 8601 format depending on OData version.
  4. Returns responses in the correct OData V2 or V4 envelope, including __metadata, navigation properties, and count annotations.
  5. Handles CSRF tokens, $expand (multi-level), $select, $top, $orderby, and $inlinecount. $filter constraints are injected into data generation — $filter=Category eq '2' produces entities where that property contains exactly '2', not post-filtered random data.

It uses Bogus for data generation and 11 entity-matching strategies to resolve EntitySet names to EntityType definitions — exact match, case-insensitive, plural/singular normalization, fuzzy Levenshtein matching, and more.

Your test code points to localhost:5000 instead of the SAP host. Same OData paths. Same query parameters. Same response format.

In a CI/CD Pipeline

Here's a GitHub Actions workflow that runs SAP integration tests with no network dependency on SAP:

name: SAP Integration Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Start MockLayer
        run: |
          ./MockLayer.Service &
          sleep 2
          # Upload EDMX metadata — no SAP connection needed
          curl -s -X POST \
            http://localhost:5000/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner \
            -H "Content-Type: application/xml" \
            -d @test/edmx/API_BUSINESS_PARTNER.edmx
        env:
          MOCKLAYER_LICENSE: ${{ secrets.MOCKLAYER_LICENSE }}

      - name: Run tests
        run: dotnet test --filter Category=SapIntegration
        env:
          SAP_BASE_URL: http://localhost:5000

The EDMX file in test/edmx/ is an export from your SAP system — the same XML you get from GET /sap/opu/odata/sap/API_BUSINESS_PARTNER/$metadata. Check it into your repo. Update it when the schema actually changes, which typically happens during SAP support package upgrades, not between sprints.

If MockLayer has network access to SAP (e.g., on a self-hosted runner), you can skip the upload entirely. It auto-fetches metadata on first request and keeps it fresh based on your TTL configuration. See the configuration reference for details.

Test Code in Four Languages

The test code is identical whether you're hitting real SAP or MockLayer. You're calling the same OData endpoints and getting the same response format.

.NET

[Fact]
public async Task GetBusinessPartners_ReturnsValidODataResponse()
{
    var client = new HttpClient
    {
        BaseAddress = new Uri(
            Environment.GetEnvironmentVariable("SAP_BASE_URL")
            ?? "http://localhost:5000")
    };

    var response = await client.GetAsync(
        "/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner"
        + "?$top=5&$expand=to_BusinessPartnerAddress");

    response.EnsureSuccessStatusCode();

    using var json = JsonDocument.Parse(
        await response.Content.ReadAsStringAsync());
    var results = json.RootElement
        .GetProperty("d").GetProperty("results");

    Assert.Equal(5, results.GetArrayLength());
    foreach (var bp in results.EnumerateArray())
    {
        Assert.False(string.IsNullOrEmpty(
            bp.GetProperty("BusinessPartner").GetString()));
        Assert.True(
            bp.TryGetProperty("to_BusinessPartnerAddress", out _));
    }
}

Java

@Test
void getBusinessPartners_returnsValidODataResponse() throws Exception {
    var baseUrl = System.getenv()
        .getOrDefault("SAP_BASE_URL", "http://localhost:5000");

    var response = HttpClient.newHttpClient().send(
        HttpRequest.newBuilder()
            .uri(URI.create(baseUrl
                + "/sap/opu/odata/sap/API_BUSINESS_PARTNER"
                + "/A_BusinessPartner?$top=5"))
            .build(),
        HttpResponse.BodyHandlers.ofString());

    assertEquals(200, response.statusCode());

    var results = JsonParser.parseString(response.body())
        .getAsJsonObject().getAsJsonObject("d")
        .getAsJsonArray("results");

    assertEquals(5, results.size());
    assertNotNull(results.get(0).getAsJsonObject()
        .get("BusinessPartner"));
}

Python

import os, requests

def test_get_business_partners():
    base = os.environ.get("SAP_BASE_URL", "http://localhost:5000")
    resp = requests.get(
        f"{base}/sap/opu/odata/sap/API_BUSINESS_PARTNER"
        "/A_BusinessPartner?$top=5&$expand=to_BusinessPartnerAddress")

    assert resp.status_code == 200
    results = resp.json()["d"]["results"]
    assert len(results) == 5
    assert all("BusinessPartner" in bp for bp in results)
    assert all("to_BusinessPartnerAddress" in bp for bp in results)

Node.js

test('GET business partners returns valid OData', async () => {
  const base = process.env.SAP_BASE_URL || 'http://localhost:5000';
  const res = await fetch(
    `${base}/sap/opu/odata/sap/API_BUSINESS_PARTNER`
    + `/A_BusinessPartner?$top=5`
  );

  expect(res.ok).toBe(true);
  const { d: { results } } = await res.json();

  expect(results).toHaveLength(5);
  results.forEach(bp => {
    expect(bp.BusinessPartner).toBeDefined();
    expect(bp.__metadata.type).toContain('BusinessPartner');
  });
});

How the Five Approaches Compare

Real SAPJSON FixturesWireMockCustom ServerMockLayer
Setup time01-2 days2-5 days4-8 weeks30 minutes
Schema changesAutomaticManual updateManual updateParser updateAutomatic
EDMX awarenessN/ANoneNonePartialCore
OData envelopeCorrectHand-codedHand-codedCustom implAutomatic
Navigation propsCorrectHand-codedHand-codedCustom implAutomatic
CI/CD reliableNoYesYesYesYes
CSRF tokensYesNoManualCustomYes
MaintenanceNoneHighHighHighNone

What This Doesn't Solve

MockLayer generates data from schema definitions. It handles a specific — and large — category of integration tests, but not everything:

  • No state between requests. Each request generates fresh data. If your test creates a business partner via POST and reads it back via GET, MockLayer won't return the same entity. For CRUD sequence testing, you need a stateful mock or a real SAP sandbox.
  • No business logic validation. It won't verify that a purchase order total matches its line items. The data is structurally correct, not semantically correct.
  • No $batch or function imports. Bundled multi-operation requests and custom SAP function calls aren't supported.
  • Not a SAP replacement. For end-to-end testing of business processes that span multiple SAP transactions, you still need SAP.

What It Does Cover

Integration tests do more than validate data contracts. They verify that your HTTP client is configured correctly — base URLs, timeouts, retry policies. They confirm that your authentication headers reach the server. They exercise your serialization and deserialization pipeline end-to-end. They prove that your application can actually reach a service over the network.

MockLayer covers all of this. It runs as a real HTTP server on localhost:5000. Your test code makes a real HTTP request, sends real headers, receives a real response over the wire, and parses real JSON. The full request/response cycle is exercised — network layer, HTTP client configuration, header construction, response deserialization, null handling, error paths.

What MockLayer specifically removes from the equation is the dependency on SAP for the data behind that response. Instead of SAP generating the response from its database and business logic, MockLayer generates it from the EDMX schema. The structural contract — field names, data types, OData envelope format, navigation property nesting — is identical.

For the majority of SAP integration tests, the question you're answering is: "Does my code correctly handle what SAP sends back?" Not: "Does SAP's business logic produce the right result?" MockLayer lets you answer the first question reliably, offline, in every CI run, without maintaining stub files.


MockLayer is a standalone binary for Windows, Linux, and macOS with no runtime dependencies. If you want to try it, there's a free 5-day trial that works without a SAP connection — upload an EDMX file and test against it locally.

Documentation | Pricing