Part 52: SOAP Integration Project in Salesforce
Welcome back to the Salesforce series. In the previous installment we explored the theory behind SOAP-based web services — the XML envelope, WSDL structure, and how Salesforce fits into the SOAP ecosystem. Theory is important, but integrations only click when you build one end to end.
In this post we are going to do exactly that. We will take an external SOAP web service, import its WSDL into Salesforce, test it with SOAP UI, build an Apex service class that calls it, and then write a full test class using WebServiceMock. By the end you will have a working pattern you can reuse for any SOAP integration.
The project we are building is a currency conversion integration. We will call an external SOAP service to convert amounts between currencies and store the results on a custom object in Salesforce. This is a common real-world pattern — pulling exchange rates, converting invoice totals, or normalizing revenue figures across regions.
Let us get started.
Understanding the WSDL
Before we import anything, we need to understand what a WSDL actually is and what Salesforce does with it.
What Is a WSDL?
WSDL stands for Web Services Description Language. It is an XML document that describes everything a SOAP web service can do:
- Types — The data types used in request and response messages (defined using XML Schema).
- Messages — The input and output payloads for each operation.
- PortType — The collection of operations the service exposes (think of it as an interface).
- Binding — How the operations map to a transport protocol (almost always HTTP).
- Service — The actual endpoint URL where the service lives.
Think of a WSDL as an API contract. It tells the consumer exactly what to send, what to expect back, and where to send it. There is no guesswork involved.
A Sample WSDL
Here is a simplified version of what our currency conversion WSDL looks like:
<?xml version="1.0" encoding="UTF-8"?>
<definitions name="CurrencyConverterService"
targetNamespace="http://www.example.com/currency"
xmlns="http://schemas.xmlsoap.org/wsdl/"
xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
xmlns:tns="http://www.example.com/currency"
xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<types>
<xsd:schema targetNamespace="http://www.example.com/currency">
<xsd:element name="ConvertCurrencyRequest">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="fromCurrency" type="xsd:string"/>
<xsd:element name="toCurrency" type="xsd:string"/>
<xsd:element name="amount" type="xsd:double"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="ConvertCurrencyResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="convertedAmount" type="xsd:double"/>
<xsd:element name="exchangeRate" type="xsd:double"/>
<xsd:element name="timestamp" type="xsd:string"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
</xsd:schema>
</types>
<message name="ConvertCurrencyInput">
<part name="parameters" element="tns:ConvertCurrencyRequest"/>
</message>
<message name="ConvertCurrencyOutput">
<part name="parameters" element="tns:ConvertCurrencyResponse"/>
</message>
<portType name="CurrencyConverterPortType">
<operation name="ConvertCurrency">
<input message="tns:ConvertCurrencyInput"/>
<output message="tns:ConvertCurrencyOutput"/>
</operation>
</portType>
<binding name="CurrencyConverterBinding"
type="tns:CurrencyConverterPortType">
<soap:binding style="document"
transport="http://schemas.xmlsoap.org/soap/http"/>
<operation name="ConvertCurrency">
<soap:operation
soapAction="http://www.example.com/currency/ConvertCurrency"/>
<input><soap:body use="literal"/></input>
<output><soap:body use="literal"/></output>
</operation>
</binding>
<service name="CurrencyConverterService">
<port name="CurrencyConverterPort"
binding="tns:CurrencyConverterBinding">
<soap:address
location="https://api.example.com/currency/soap"/>
</port>
</service>
</definitions>
This WSDL tells us:
- The service lives at
https://api.example.com/currency/soap. - It has one operation called
ConvertCurrency. - The request takes
fromCurrency,toCurrency, andamount. - The response returns
convertedAmount,exchangeRate, andtimestamp.
Setting Up the WSDL in Salesforce
Salesforce can consume external SOAP services by importing a WSDL and auto-generating Apex classes from it. This is one of the platform’s most powerful integration features.
How Salesforce Generates Apex from WSDL
When you import a WSDL, Salesforce parses the XML and creates Apex classes that mirror the WSDL structure:
- Each complex type becomes an inner class with properties.
- Each operation becomes a method on a port type class.
- The endpoint URL is stored as a class-level variable.
- Namespaces are preserved as string constants.
The generated code handles all the XML serialization and deserialization for you. You call a normal Apex method and Salesforce builds the SOAP envelope, sends the HTTP request, parses the XML response, and hands you back an Apex object.
Importing the WSDL
Here is the step-by-step process:
- Navigate to Setup and search for Apex Classes.
- Click Generate from WSDL.
- Click Choose File and select your
.wsdlfile. - Click Parse WSDL. Salesforce reads the file and shows you the namespaces and class names it plans to create.
- Review the suggested class names. You can rename them here — this is your only chance before the code is generated.
- Click Generate Apex Code.
- If successful, you will see confirmation and can click Done.
Common Import Errors and How to Handle Them
WSDL imports do not always go smoothly. Here are the errors you are most likely to encounter:
Unsupported Schema Types
Salesforce does not support every XML Schema type. If the WSDL uses xsd:anyType, xsd:any, or certain complex inheritance patterns, the import will fail. The error message typically reads:
Error: Unsupported schema type
The fix is to edit the WSDL before importing. Replace unsupported types with supported equivalents:
- Replace
xsd:anyTypewithxsd:string(you will parse the content manually in Apex). - Remove
xsd:anyelements or replace them with explicitly typed elements. - Flatten complex type inheritance into a single type definition.
WSDL Size Limits
Salesforce has a size limit for WSDL imports (roughly 1 MB for the WSDL file and a combined limit on the generated code). If your WSDL is too large, you will see:
Error: Maximum size of generated code exceeded
The fix is to trim the WSDL to include only the operations you need. Most enterprise WSDLs define dozens of operations, but you may only need two or three. Remove everything you do not plan to call.
Duplicate Class Names
If a class with the same name already exists in your org, the import fails. Either delete the old class first or rename the new one during the import step.
Missing Endpoint
Some WSDLs omit the <soap:address> element. Salesforce requires it. If it is missing, add it to the WSDL XML before importing:
<soap:address location="https://your-endpoint-url.com/service"/>
Manually Adjusting Generated Code
Even after a successful import, you may need to tweak the generated Apex. Common adjustments include:
- Changing the endpoint URL — The generated code hard-codes the endpoint from the WSDL. You often need to make this configurable (for example, pulling it from a Custom Metadata Type) so you can point to different environments.
- Adding timeout settings — The generated code does not set a timeout by default. For production reliability, you should always set one.
- Fixing namespace issues — Occasionally the generated namespace constants do not match what the server expects. Compare the generated code against the WSDL to verify.
Here is what the generated Apex code looks like for our currency converter. I have added comments to explain each section:
// Auto-generated from WSDL import, with manual adjustments noted
public class CurrencyConverterService {
// Inner class for the request type
public class ConvertCurrencyRequest {
public String fromCurrency;
public String toCurrency;
public Double amount;
private String[] fromCurrency_type_info = new String[]{
'fromCurrency', 'http://www.example.com/currency',
null, '1', '1', 'false'
};
private String[] toCurrency_type_info = new String[]{
'toCurrency', 'http://www.example.com/currency',
null, '1', '1', 'false'
};
private String[] amount_type_info = new String[]{
'amount', 'http://www.example.com/currency',
null, '1', '1', 'false'
};
private String[] apex_schema_type_info = new String[]{
'http://www.example.com/currency',
'true', 'false'
};
private String[] field_order_type_info = new String[]{
'fromCurrency', 'toCurrency', 'amount'
};
}
// Inner class for the response type
public class ConvertCurrencyResponse {
public Double convertedAmount;
public Double exchangeRate;
public String timestamp;
private String[] convertedAmount_type_info = new String[]{
'convertedAmount', 'http://www.example.com/currency',
null, '1', '1', 'false'
};
private String[] exchangeRate_type_info = new String[]{
'exchangeRate', 'http://www.example.com/currency',
null, '1', '1', 'false'
};
private String[] timestamp_type_info = new String[]{
'timestamp', 'http://www.example.com/currency',
null, '1', '1', 'false'
};
private String[] apex_schema_type_info = new String[]{
'http://www.example.com/currency',
'true', 'false'
};
private String[] field_order_type_info = new String[]{
'convertedAmount', 'exchangeRate', 'timestamp'
};
}
// The port type class — this is what you call
public class CurrencyConverterPort {
public String endpoint_x =
'https://api.example.com/currency/soap';
public Map<String, String> inputHttpHeaders_x;
public Map<String, String> outputHttpHeaders_x;
public String clientCertName_x;
public String clientCert_x;
public String clientCertPasswd_x;
public Integer timeout_x; // MANUAL ADD: set a timeout
private String[] ns_map_type_info = new String[]{
'http://www.example.com/currency',
'CurrencyConverterService'
};
public ConvertCurrencyResponse ConvertCurrency(
String fromCurrency,
String toCurrency,
Double amount
) {
CurrencyConverterService.ConvertCurrencyRequest
request_x = new
CurrencyConverterService.ConvertCurrencyRequest();
request_x.fromCurrency = fromCurrency;
request_x.toCurrency = toCurrency;
request_x.amount = amount;
CurrencyConverterService.ConvertCurrencyResponse
response_x;
Map<String, CurrencyConverterService
.ConvertCurrencyResponse> response_map_x =
new Map<String,
CurrencyConverterService
.ConvertCurrencyResponse>();
response_map_x.put(
'response_x', response_x
);
WebServiceCallout.invoke(
this,
'{http://www.example.com/currency}ConvertCurrency',
new String[]{
'http://www.example.com/currency',
'ConvertCurrency',
'http://www.example.com/currency',
'ConvertCurrencyRequest',
'http://www.example.com/currency',
'ConvertCurrencyResponse',
'CurrencyConverterService.ConvertCurrencyResponse'
},
new Object[]{request_x},
new String[]{response_map_x}
);
response_x = response_map_x.get('response_x');
return response_x;
}
}
}
The key things to notice:
- The
_type_infoarrays tell Salesforce how to serialize each field into XML. - The
ns_map_type_infomaps XML namespaces to Apex class names. WebServiceCallout.invokeis the engine that builds the SOAP envelope and makes the HTTP call.- The
timeout_xproperty was not in the auto-generated code — I added it manually.
Adding the Remote Site Setting
Before Salesforce will allow any callout, you must register the endpoint as a Remote Site Setting:
- Go to Setup and search for Remote Site Settings.
- Click New Remote Site.
- Enter a name (e.g.,
CurrencyConverterAPI). - Enter the URL:
https://api.example.com. - Check Active and click Save.
If you skip this step, every callout will throw a System.CalloutException with the message “Unauthorized endpoint.”
Testing Our Connection with SOAP UI
Before writing any Apex, it is smart to verify the external service works as expected. SOAP UI is the standard tool for this.
Installing SOAP UI
SOAP UI is a free, open-source tool for testing SOAP and REST services. Download it from the official SoapUI website. Install it like any other desktop application — there is a free version and a Pro version. The free version is all you need.
Creating a Project from the WSDL
- Open SOAP UI and click File > New SOAP Project.
- Give it a name (e.g.,
Currency Converter Test). - In the Initial WSDL field, paste the URL to the WSDL or browse to the local file.
- Check Create Requests — this auto-generates sample request XML for every operation.
- Click OK.
SOAP UI reads the WSDL and builds a project tree. You will see the service, the binding, and each operation listed. Expand the ConvertCurrency operation and you will find a pre-built request.
Testing the Endpoint
The auto-generated request looks something like this:
<soapenv:Envelope
xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:cur="http://www.example.com/currency">
<soapenv:Header/>
<soapenv:Body>
<cur:ConvertCurrencyRequest>
<cur:fromCurrency>?</cur:fromCurrency>
<cur:toCurrency>?</cur:toCurrency>
<cur:amount>?</cur:amount>
</cur:ConvertCurrencyRequest>
</soapenv:Body>
</soapenv:Envelope>
Replace the ? placeholders with real values:
<cur:fromCurrency>USD</cur:fromCurrency>
<cur:toCurrency>EUR</cur:toCurrency>
<cur:amount>1000.00</cur:amount>
Click the green play button to send the request. If everything is working, you get a response like:
<soapenv:Envelope
xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<ConvertCurrencyResponse
xmlns="http://www.example.com/currency">
<convertedAmount>920.50</convertedAmount>
<exchangeRate>0.9205</exchangeRate>
<timestamp>2026-05-01T10:30:00Z</timestamp>
</ConvertCurrencyResponse>
</soapenv:Body>
</soapenv:Envelope>
Reading the XML Response
The response structure mirrors what the WSDL defined:
convertedAmount— The result of multiplying our amount by the exchange rate.exchangeRate— The rate used for the conversion.timestamp— When the rate was fetched.
These are the exact fields that the generated Apex ConvertCurrencyResponse class will populate.
Debugging Common Issues
HTTP 500 Internal Server Error — The service crashed. Check if your request XML is well-formed and matches the expected schema. A missing namespace prefix or a wrong element name can trigger this.
HTTP 401 Unauthorized — The service requires authentication. Check if you need to add a WS-Security header, an API key header, or Basic Auth credentials. In SOAP UI, right-click the request and look under Auth settings.
Connection Refused — The endpoint URL is wrong or the service is down. Verify the URL in your browser or try a simple curl command from the terminal.
Timeout — The service is taking too long. In SOAP UI, go to Preferences > HTTP Settings and increase the socket timeout.
Empty Response — The service returned a 200 status but the body is empty. This usually means the request was valid but the service has no data for those parameters (e.g., an unsupported currency code).
Once you have confirmed the service works in SOAP UI, you know the WSDL is correct, the endpoint is reachable, and the request/response format is as expected. Now we can build the Apex integration with confidence.
The Custom Object
Before we write the service class, we need a place to store conversion results. Create a custom object called Currency_Conversion__c with these fields:
| Field Label | API Name | Type |
|---|---|---|
| From Currency | From_Currency__c | Text(3) |
| To Currency | To_Currency__c | Text(3) |
| Original Amount | Original_Amount__c | Currency(16,2) |
| Converted Amount | Converted_Amount__c | Currency(16,2) |
| Exchange Rate | Exchange_Rate__c | Number(10,6) |
| Conversion Date | Conversion_Date__c | DateTime |
| Status | Status__c | Picklist (Success, Failed) |
| Error Message | Error_Message__c | Long Text Area(5000) |
The Status__c and Error_Message__c fields are important. In any integration, you must track whether each transaction succeeded or failed and capture the error details. This makes debugging production issues dramatically easier.
Creating the Integration Service Class
Now we build the real service class. This is not the auto-generated WSDL code — it is the layer that sits on top of it and provides a clean API for the rest of your application.
Why a Separate Service Class?
You never want the rest of your codebase calling the auto-generated WSDL classes directly. There are several reasons:
- Encapsulation — The generated code is verbose and tied to the WSDL structure. A service class provides a clean interface.
- Error Handling — The generated code throws raw exceptions. The service class catches them and returns meaningful results.
- Configurability — The service class can pull endpoints and timeouts from Custom Metadata Types.
- Testability — The service class is easier to mock and test.
- Change Isolation — If the WSDL changes, you only update the service class, not every caller.
The Service Class
public with sharing class CurrencyConversionService {
// Custom exception for integration errors
public class CurrencyConversionException extends Exception {}
// Wrapper to return structured results
public class ConversionResult {
public Boolean success;
public Double convertedAmount;
public Double exchangeRate;
public DateTime conversionTimestamp;
public String errorMessage;
public ConversionResult() {
this.success = false;
this.convertedAmount = 0;
this.exchangeRate = 0;
this.conversionTimestamp = null;
this.errorMessage = null;
}
}
// Default timeout in milliseconds
private static final Integer DEFAULT_TIMEOUT = 30000;
/**
* Convert a currency amount using the external SOAP service.
* Returns a ConversionResult with either the converted data
* or error details.
*/
public static ConversionResult convertCurrency(
String fromCurrency,
String toCurrency,
Double amount
) {
ConversionResult result = new ConversionResult();
// Validate inputs before making the callout
if (String.isBlank(fromCurrency)
|| String.isBlank(toCurrency)) {
result.errorMessage =
'Currency codes cannot be blank.';
return result;
}
if (amount == null || amount <= 0) {
result.errorMessage =
'Amount must be greater than zero.';
return result;
}
if (fromCurrency.length() != 3
|| toCurrency.length() != 3) {
result.errorMessage =
'Currency codes must be 3 characters '
+ '(ISO 4217 format).';
return result;
}
try {
// Create the port and configure it
CurrencyConverterService.CurrencyConverterPort port =
new CurrencyConverterService
.CurrencyConverterPort();
// Pull the endpoint from Custom Metadata if available
// Fall back to the WSDL default
String configuredEndpoint = getConfiguredEndpoint();
if (String.isNotBlank(configuredEndpoint)) {
port.endpoint_x = configuredEndpoint;
}
// Always set a timeout
port.timeout_x = DEFAULT_TIMEOUT;
// Make the callout
CurrencyConverterService.ConvertCurrencyResponse
response = port.ConvertCurrency(
fromCurrency.toUpperCase(),
toCurrency.toUpperCase(),
amount
);
// Map the response
if (response != null) {
result.success = true;
result.convertedAmount =
response.convertedAmount;
result.exchangeRate = response.exchangeRate;
result.conversionTimestamp =
parseTimestamp(response.timestamp);
} else {
result.errorMessage =
'Service returned a null response.';
}
} catch (System.CalloutException e) {
result.errorMessage =
'Callout failed: ' + e.getMessage();
} catch (Exception e) {
result.errorMessage =
'Unexpected error: ' + e.getMessage()
+ ' | Type: ' + e.getTypeName();
}
return result;
}
/**
* Convert and save — calls the service and persists the
* result to a Currency_Conversion__c record.
*/
public static Currency_Conversion__c convertAndSave(
String fromCurrency,
String toCurrency,
Double amount
) {
ConversionResult result =
convertCurrency(fromCurrency, toCurrency, amount);
Currency_Conversion__c record =
new Currency_Conversion__c();
record.From_Currency__c = fromCurrency.toUpperCase();
record.To_Currency__c = toCurrency.toUpperCase();
record.Original_Amount__c = amount;
if (result.success) {
record.Converted_Amount__c =
result.convertedAmount;
record.Exchange_Rate__c = result.exchangeRate;
record.Conversion_Date__c =
result.conversionTimestamp;
record.Status__c = 'Success';
} else {
record.Status__c = 'Failed';
record.Error_Message__c = result.errorMessage;
}
insert record;
return record;
}
/**
* Bulk convert — process multiple conversions and return
* all records. Useful for batch scenarios.
*/
public static List<Currency_Conversion__c> bulkConvert(
List<Map<String, Object>> conversionRequests
) {
List<Currency_Conversion__c> records =
new List<Currency_Conversion__c>();
for (Map<String, Object> req : conversionRequests) {
String fromCurr =
(String) req.get('fromCurrency');
String toCurr =
(String) req.get('toCurrency');
Double amt =
(Double) req.get('amount');
ConversionResult result =
convertCurrency(fromCurr, toCurr, amt);
Currency_Conversion__c record =
new Currency_Conversion__c();
record.From_Currency__c = fromCurr.toUpperCase();
record.To_Currency__c = toCurr.toUpperCase();
record.Original_Amount__c = amt;
if (result.success) {
record.Converted_Amount__c =
result.convertedAmount;
record.Exchange_Rate__c =
result.exchangeRate;
record.Conversion_Date__c =
result.conversionTimestamp;
record.Status__c = 'Success';
} else {
record.Status__c = 'Failed';
record.Error_Message__c =
result.errorMessage;
}
records.add(record);
}
if (!records.isEmpty()) {
insert records;
}
return records;
}
/**
* Retrieve the endpoint from Custom Metadata.
* Returns null if no configuration is found.
*/
private static String getConfiguredEndpoint() {
List<Integration_Setting__mdt> settings =
[SELECT Endpoint_URL__c
FROM Integration_Setting__mdt
WHERE DeveloperName = 'Currency_Converter'
AND Is_Active__c = true
LIMIT 1];
if (!settings.isEmpty()) {
return settings[0].Endpoint_URL__c;
}
return null;
}
/**
* Parse the ISO timestamp string from the SOAP response.
*/
private static DateTime parseTimestamp(String ts) {
if (String.isBlank(ts)) {
return DateTime.now();
}
try {
// Handle ISO 8601 format: 2026-05-01T10:30:00Z
ts = ts.replace('T', ' ').replace('Z', '');
return DateTime.valueOf(ts);
} catch (Exception e) {
// If parsing fails, use current time
return DateTime.now();
}
}
}
Key Design Decisions
Input Validation Before the Callout — We validate currency codes and amounts before making the expensive HTTP call. This saves callout limits and gives faster feedback.
Structured Result Object — The ConversionResult wrapper decouples the SOAP response format from the rest of our application. Callers check result.success instead of catching exceptions.
Configurable Endpoint — We query Integration_Setting__mdt for the endpoint URL. This means you can change environments (dev, staging, production) without modifying code.
Timeout Always Set — Production integrations must have a timeout. Without one, a slow external service can hold your transaction hostage until the Salesforce governor limit kills it (which is 120 seconds for synchronous callouts). Thirty seconds is a reasonable default.
Bulk Method — The bulkConvert method handles multiple conversions in a single transaction, inserting all records in one DML statement. This respects governor limits and follows Salesforce best practices.
Testing the Integration Service Class
Testing callouts in Salesforce requires mock classes. You cannot make real HTTP calls in a test context — the platform blocks them. For SOAP callouts, you implement the WebServiceMock interface.
How WebServiceMock Works
The WebServiceMock interface has one method:
void doInvoke(
Object stub,
Object request,
Map<String, Object> response,
String endpoint,
String soapAction,
String requestName,
String responseNamespace,
String responseName,
String responseType
)
When Salesforce encounters a WebServiceCallout.invoke call during a test, it routes it to your mock’s doInvoke method instead of making an actual HTTP call. Your mock builds a fake response object and puts it in the response map.
The Success Mock
@isTest
private class CurrencyConverterMock
implements WebServiceMock {
public void doInvoke(
Object stub,
Object request,
Map<String, Object> response,
String endpoint,
String soapAction,
String requestName,
String responseNamespace,
String responseName,
String responseType
) {
// Build a fake response
CurrencyConverterService.ConvertCurrencyResponse
mockResponse = new
CurrencyConverterService
.ConvertCurrencyResponse();
mockResponse.convertedAmount = 920.50;
mockResponse.exchangeRate = 0.9205;
mockResponse.timestamp = '2026-05-01T10:30:00Z';
// Put it in the response map with the key
// Salesforce expects
response.put('response_x', mockResponse);
}
}
The critical detail is the key 'response_x' in the response map. This must match the variable name used in the auto-generated WSDL class. If it does not match, the response will be null.
The Error Mock
We also need a mock that simulates a service failure:
@isTest
private class CurrencyConverterErrorMock
implements WebServiceMock {
public void doInvoke(
Object stub,
Object request,
Map<String, Object> response,
String endpoint,
String soapAction,
String requestName,
String responseNamespace,
String responseName,
String responseType
) {
// Simulate a SOAP fault by throwing a CalloutException
throw new System.CalloutException(
'SOAP Fault: Service unavailable. '
+ 'Please try again later.'
);
}
}
The Null Response Mock
And one that returns a null response to test that edge case:
@isTest
private class CurrencyConverterNullMock
implements WebServiceMock {
public void doInvoke(
Object stub,
Object request,
Map<String, Object> response,
String endpoint,
String soapAction,
String requestName,
String responseNamespace,
String responseName,
String responseType
) {
// Do not put anything in the response map
// This simulates a null response
}
}
The Comprehensive Test Class
Now we tie it all together in a test class that covers the success path, error path, null response, input validation, and the bulk operation:
@isTest
private class CurrencyConversionServiceTest {
// -------------------------------------------------------
// Test: Successful conversion
// -------------------------------------------------------
@isTest
static void testConvertCurrency_Success() {
Test.setMock(
WebServiceMock.class,
new CurrencyConverterMock()
);
Test.startTest();
CurrencyConversionService.ConversionResult result =
CurrencyConversionService.convertCurrency(
'USD', 'EUR', 1000.00
);
Test.stopTest();
System.assertEquals(true, result.success,
'Conversion should succeed');
System.assertEquals(920.50, result.convertedAmount,
'Converted amount should match mock');
System.assertEquals(0.9205, result.exchangeRate,
'Exchange rate should match mock');
System.assertNotEquals(null, result.conversionTimestamp,
'Timestamp should be parsed');
System.assertEquals(null, result.errorMessage,
'No error expected on success');
}
// -------------------------------------------------------
// Test: Callout exception (service down)
// -------------------------------------------------------
@isTest
static void testConvertCurrency_CalloutError() {
Test.setMock(
WebServiceMock.class,
new CurrencyConverterErrorMock()
);
Test.startTest();
CurrencyConversionService.ConversionResult result =
CurrencyConversionService.convertCurrency(
'USD', 'EUR', 1000.00
);
Test.stopTest();
System.assertEquals(false, result.success,
'Conversion should fail on callout error');
System.assert(
result.errorMessage.contains('Callout failed'),
'Error message should indicate callout failure. '
+ 'Actual: ' + result.errorMessage
);
}
// -------------------------------------------------------
// Test: Null response from service
// -------------------------------------------------------
@isTest
static void testConvertCurrency_NullResponse() {
Test.setMock(
WebServiceMock.class,
new CurrencyConverterNullMock()
);
Test.startTest();
CurrencyConversionService.ConversionResult result =
CurrencyConversionService.convertCurrency(
'USD', 'EUR', 1000.00
);
Test.stopTest();
System.assertEquals(false, result.success,
'Conversion should fail on null response');
System.assert(
result.errorMessage.contains('null response'),
'Error should mention null response'
);
}
// -------------------------------------------------------
// Test: Blank currency code
// -------------------------------------------------------
@isTest
static void testConvertCurrency_BlankCurrencyCode() {
// No mock needed — validation happens before callout
Test.startTest();
CurrencyConversionService.ConversionResult result =
CurrencyConversionService.convertCurrency(
'', 'EUR', 1000.00
);
Test.stopTest();
System.assertEquals(false, result.success,
'Should fail with blank currency code');
System.assert(
result.errorMessage.contains('cannot be blank'),
'Error should mention blank currency'
);
}
// -------------------------------------------------------
// Test: Invalid currency code length
// -------------------------------------------------------
@isTest
static void testConvertCurrency_InvalidCurrencyLength() {
Test.startTest();
CurrencyConversionService.ConversionResult result =
CurrencyConversionService.convertCurrency(
'US', 'EURO', 1000.00
);
Test.stopTest();
System.assertEquals(false, result.success,
'Should fail with invalid currency length');
System.assert(
result.errorMessage.contains('3 characters'),
'Error should mention 3-character requirement'
);
}
// -------------------------------------------------------
// Test: Zero amount
// -------------------------------------------------------
@isTest
static void testConvertCurrency_ZeroAmount() {
Test.startTest();
CurrencyConversionService.ConversionResult result =
CurrencyConversionService.convertCurrency(
'USD', 'EUR', 0
);
Test.stopTest();
System.assertEquals(false, result.success,
'Should fail with zero amount');
System.assert(
result.errorMessage.contains('greater than zero'),
'Error should mention amount requirement'
);
}
// -------------------------------------------------------
// Test: Negative amount
// -------------------------------------------------------
@isTest
static void testConvertCurrency_NegativeAmount() {
Test.startTest();
CurrencyConversionService.ConversionResult result =
CurrencyConversionService.convertCurrency(
'USD', 'EUR', -500.00
);
Test.stopTest();
System.assertEquals(false, result.success,
'Should fail with negative amount');
}
// -------------------------------------------------------
// Test: Convert and save — success path
// -------------------------------------------------------
@isTest
static void testConvertAndSave_Success() {
Test.setMock(
WebServiceMock.class,
new CurrencyConverterMock()
);
Test.startTest();
Currency_Conversion__c record =
CurrencyConversionService.convertAndSave(
'USD', 'EUR', 1000.00
);
Test.stopTest();
System.assertNotEquals(null, record.Id,
'Record should be inserted');
System.assertEquals('Success', record.Status__c,
'Status should be Success');
System.assertEquals('USD', record.From_Currency__c,
'From currency should be USD');
System.assertEquals('EUR', record.To_Currency__c,
'To currency should be EUR');
System.assertEquals(1000.00, record.Original_Amount__c,
'Original amount should be 1000');
System.assertEquals(920.50, record.Converted_Amount__c,
'Converted amount should match');
System.assertEquals(0.9205, record.Exchange_Rate__c,
'Exchange rate should match');
}
// -------------------------------------------------------
// Test: Convert and save — failure path
// -------------------------------------------------------
@isTest
static void testConvertAndSave_Failure() {
Test.setMock(
WebServiceMock.class,
new CurrencyConverterErrorMock()
);
Test.startTest();
Currency_Conversion__c record =
CurrencyConversionService.convertAndSave(
'USD', 'EUR', 1000.00
);
Test.stopTest();
System.assertNotEquals(null, record.Id,
'Record should still be inserted on failure');
System.assertEquals('Failed', record.Status__c,
'Status should be Failed');
System.assertNotEquals(null, record.Error_Message__c,
'Error message should be populated');
}
// -------------------------------------------------------
// Test: Bulk convert
// -------------------------------------------------------
@isTest
static void testBulkConvert() {
Test.setMock(
WebServiceMock.class,
new CurrencyConverterMock()
);
List<Map<String, Object>> requests =
new List<Map<String, Object>>();
Map<String, Object> req1 = new Map<String, Object>{
'fromCurrency' => 'USD',
'toCurrency' => 'EUR',
'amount' => 1000.00
};
Map<String, Object> req2 = new Map<String, Object>{
'fromCurrency' => 'GBP',
'toCurrency' => 'JPY',
'amount' => 500.00
};
requests.add(req1);
requests.add(req2);
Test.startTest();
List<Currency_Conversion__c> records =
CurrencyConversionService.bulkConvert(requests);
Test.stopTest();
System.assertEquals(2, records.size(),
'Should create two records');
for (Currency_Conversion__c rec : records) {
System.assertNotEquals(null, rec.Id,
'Each record should be inserted');
System.assertEquals('Success', rec.Status__c,
'Each record should succeed');
}
}
// -------------------------------------------------------
// Test: ConversionResult default state
// -------------------------------------------------------
@isTest
static void testConversionResult_Defaults() {
CurrencyConversionService.ConversionResult result =
new CurrencyConversionService.ConversionResult();
System.assertEquals(false, result.success,
'Default success should be false');
System.assertEquals(0, result.convertedAmount,
'Default convertedAmount should be 0');
System.assertEquals(0, result.exchangeRate,
'Default exchangeRate should be 0');
System.assertEquals(null, result.conversionTimestamp,
'Default timestamp should be null');
System.assertEquals(null, result.errorMessage,
'Default errorMessage should be null');
}
}
What Each Test Covers
| Test Method | What It Validates |
|---|---|
testConvertCurrency_Success | Happy path — mock returns valid data, result is populated correctly |
testConvertCurrency_CalloutError | Service throws an exception, error is caught and surfaced |
testConvertCurrency_NullResponse | Service returns null, handled gracefully |
testConvertCurrency_BlankCurrencyCode | Input validation rejects blank strings before callout |
testConvertCurrency_InvalidCurrencyLength | Input validation enforces ISO 4217 three-character codes |
testConvertCurrency_ZeroAmount | Input validation rejects zero amounts |
testConvertCurrency_NegativeAmount | Input validation rejects negative amounts |
testConvertAndSave_Success | End-to-end success — callout plus record creation |
testConvertAndSave_Failure | End-to-end failure — record created with Failed status |
testBulkConvert | Multiple conversions in a single transaction |
testConversionResult_Defaults | Wrapper class initializes correctly |
Key Testing Patterns to Remember
Test.setMock is per-transaction. You call it once and it applies to all callouts within that Test.startTest() / Test.stopTest() block. You do not need to call it multiple times for multiple callouts.
Validation tests do not need mocks. When your service class validates inputs before making the callout, those validation paths can be tested without any mock at all. This is one of the benefits of doing validation first.
Always test both success and failure. It is tempting to only test the happy path. But integration failures are inevitable in production — network issues, service outages, bad data, timeouts. Your test class should prove that your code handles all of these gracefully.
Assert on specific values, not just null checks. Compare the converted amount, exchange rate, and status against the exact values your mock returns. This catches subtle mapping bugs where the right fields get swapped or truncated.
Putting It All Together
Let us recap the complete architecture of this integration:
External SOAP Service
|
| (SOAP over HTTPS)
v
CurrencyConverterService.cls <-- Auto-generated from WSDL
|
| (Apex method call)
v
CurrencyConversionService.cls <-- Our service layer
|
| (DML insert)
v
Currency_Conversion__c <-- Custom object for results
The generated WSDL class handles serialization. The service class handles logic, validation, and error handling. The custom object stores results. The test class mocks the callout layer and verifies everything end to end.
This layered architecture scales. When the external service adds new operations, you import the updated WSDL, add methods to the service class, and write new tests. The rest of your application does not change.
Production Considerations
Before deploying this to production, consider these additions:
Logging — Add a custom object or platform event to log every callout attempt, response time, and status. When something breaks at 2 AM, you will be glad you have logs.
Retry Logic — For transient failures (timeouts, 503 errors), consider queuing a retry via a Platform Event or Queueable class rather than immediately returning a failure.
Circuit Breaker — If the external service is consistently failing, stop calling it. Use a Custom Metadata flag to disable the integration and alert your team.
Named Credentials — For services requiring authentication, use Named Credentials instead of storing credentials in code or Custom Settings. Named Credentials handle OAuth tokens, certificates, and basic auth securely.
Governor Limits — Remember that each synchronous Apex transaction allows a maximum of 100 callouts. If you need more, you must move to asynchronous processing (which we will cover soon).
Summary
In this post we built a complete SOAP integration from scratch:
- WSDLs describe a SOAP service’s operations, types, and endpoint. Salesforce can import them and auto-generate Apex classes.
- SOAP UI lets you test the external service independently before writing any Apex, saving debugging time.
- A service layer class wraps the generated code with input validation, error handling, configurable endpoints, and structured result objects.
- WebServiceMock is the testing interface for SOAP callouts. You implement
doInvoke, build a mock response, and register it withTest.setMock. - Comprehensive tests cover success paths, error paths, input validation, null responses, and bulk operations.
The pattern we built here — WSDL import, service layer, mock testing — is the same pattern used in enterprise Salesforce orgs for integrations with ERP systems, payment processors, shipping providers, and more. Master it once and you can apply it everywhere.
In the next post, Part 53, we will shift gears to The Basics of Async Apex — understanding why asynchronous processing matters in Salesforce and exploring the four async tools at your disposal: Future methods, Queueable Apex, Batch Apex, and Scheduled Apex. See you there.