Платіжний код має унікальний виклик: він взаємодіє із зовнішніми системами, що мають свій lifecycle, і кожен «живий» виклик потенційно впливає на фінансові записи. Не можна просто «запустити тести» і підключитися до реального LiqPay.
Хороша платіжна тест-стратегія базується на чотирьох рівнях:
LiqPaySignatureHelperpublic class LiqPaySignatureTests
{
[Fact]
public void GenerateSignature_ShouldProduceCorrectHash()
{
// Arrange
const string data = "dGVzdA=="; // Base64("test")
const string privateKey = "test_private_key";
// Act
var signature = LiqPaySignatureHelper.GenerateSignature(data, privateKey);
// Assert: очікуваний підпис розраховано заздалегідь вручну
// SHA1(privateKey + data + privateKey) → Base64
signature.Should().NotBeNullOrEmpty();
// Детермінований алгоритм: той самий input → той самий output
var signature2 = LiqPaySignatureHelper.GenerateSignature(data, privateKey);
signature.Should().Be(signature2);
}
[Fact]
public void VerifySignature_InvalidSignature_ShouldReturnFalse()
{
const string data = "dGVzdA==";
const string privateKey = "test_private_key";
const string fakeSignature = "invalidsignaturebase64==";
var result = LiqPaySignatureHelper.VerifySignature(data, fakeSignature, privateKey);
result.Should().BeFalse();
}
[Fact]
public void VerifySignature_ValidSignature_ShouldReturnTrue()
{
const string data = "dGVzdA==";
const string privateKey = "test_private_key";
var signature = LiqPaySignatureHelper.GenerateSignature(data, privateKey);
var result = LiqPaySignatureHelper.VerifySignature(data, signature, privateKey);
result.Should().BeTrue();
}
}
PaymentStateMachinepublic class PaymentStateMachineTests
{
[Theory]
[InlineData(PaymentStatus.Created, PaymentStatus.Pending, true)]
[InlineData(PaymentStatus.Pending, PaymentStatus.Authorized, true)]
[InlineData(PaymentStatus.Settled, PaymentStatus.Refunded, true)]
[InlineData(PaymentStatus.Failed, PaymentStatus.Settled, false)]
[InlineData(PaymentStatus.Settled, PaymentStatus.Pending, false)]
[InlineData(PaymentStatus.Cancelled, PaymentStatus.Active, false)]
public void CanTransition_ShouldReturnCorrectResult(
PaymentStatus from, PaymentStatus to, bool expected)
{
var result = PaymentStateMachine.CanTransition(from, to);
result.Should().Be(expected);
}
[Fact]
public void EnsureTransition_InvalidTransition_ShouldThrow()
{
var act = () => PaymentStateMachine.EnsureTransition(
PaymentStatus.Failed, PaymentStatus.Settled);
act.Should().Throw<InvalidOperationException>()
.WithMessage("*Invalid payment state transition*");
}
}
IPaymentProviderpublic class MockPaymentProvider : IPaymentProvider
{
public string ProviderName => "mock";
// Контролюємо поведінку через ці властивості
public bool ShouldSucceed { get; set; } = true;
public PaymentStatus ResultStatus { get; set; } = PaymentStatus.Settled;
public string? ErrorMessage { get; set; }
// Перехоплюємо всі виклики для assertions
public List<PaymentRequest> CreateCalls { get; } = [];
public List<string> RefundCalls { get; } = [];
public Task<CreatePaymentResult> CreatePaymentAsync(
PaymentRequest request, CancellationToken ct = default)
{
CreateCalls.Add(request);
if (!ShouldSucceed)
return Task.FromResult(new CreatePaymentResult(
false, null, null, null, ErrorMessage ?? "Mock error"));
return Task.FromResult(new CreatePaymentResult(
true,
CheckoutUrl: "https://mock-psp.com/checkout",
FormData: null,
ProviderPaymentId: $"mock_txn_{Guid.NewGuid():N}",
ErrorMessage: null));
}
public Task<PaymentStatusResult> GetPaymentStatusAsync(
string providerTransactionId, CancellationToken ct = default)
=> Task.FromResult(new PaymentStatusResult(
true, ResultStatus, providerTransactionId, null));
public Task<RefundResult> RefundAsync(
string providerTransactionId, decimal amount, CancellationToken ct = default)
{
RefundCalls.Add(providerTransactionId);
return Task.FromResult(new RefundResult(ShouldSucceed, $"mock_refund_{Guid.NewGuid():N}", null));
}
public Task<WebhookResult> ProcessWebhookAsync(
HttpRequest request, CancellationToken ct = default)
=> throw new NotImplementedException();
}
WebApplicationFactorypublic class PaymentEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public PaymentEndpointTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Замінюємо реальний провайдер на mock
services.AddScoped<MockPaymentProvider>();
services.AddScoped<IPaymentProviderFactory>(sp =>
{
var mock = sp.GetRequiredService<MockPaymentProvider>();
return new MockPaymentProviderFactory(mock);
});
// In-memory база для ізоляції тестів
services.AddDbContext<AppDbContext>(opts =>
opts.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}"));
});
});
}
[Fact]
public async Task POST_CreatePayment_ShouldReturn_CheckoutUrl()
{
var client = _factory.CreateClient();
var request = new
{
orderId = Guid.NewGuid(),
amount = 100m,
currency = "UAH",
description = "Test payment"
};
var response = await client.PostAsJsonAsync("/api/payments", request);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("checkoutUrl").GetString()
.Should().StartWith("https://mock-psp.com/checkout");
}
[Fact]
public async Task POST_LiqPayWebhook_ValidSignature_ShouldUpdatePaymentStatus()
{
const string testPrivateKey = "test_private_key";
var client = _factory.CreateClient();
// Спочатку створимо платіж
var paymentId = Guid.NewGuid().ToString();
var payload = new { order_id = paymentId, status = "success", transaction_id = "txn_123" };
var data = LiqPaySignatureHelper.EncodeData(payload);
var sig = LiqPaySignatureHelper.GenerateSignature(data, testPrivateKey);
var content = new FormUrlEncodedContent([
new("data", data),
new("signature", sig)
]);
var response = await client.PostAsync("/webhooks/liqpay", content);
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
| Картка | Поведінка |
|---|---|
4242424242424242 | Успішна оплата |
4000000000000002 | Decline (insufficient funds) |
4000000000003220 | 3DS challenged |
| Картка | Поведінка |
|---|---|
4242424242424242 | Успішна оплата |
5105105105105100 | Decline |
| Картка | Поведінка |
|---|---|
4242 4242 4242 4242 | Успішна оплата |
4000 0000 0000 0002 | Decline (card_declined) |
4000 0025 0000 3155 | 3DS authentication required |
4000 0000 0000 9995 | Decline (insufficient_funds) |
4000 0000 0000 0069 | Decline (expired_card) |
{
"LiqPay": {
"PublicKey": "test_public_key",
"PrivateKey": "test_private_key",
"IsSandbox": true
},
"Stripe": {
"SecretKey": "sk_test_placeholder",
"WebhookSecret": "whsec_test_placeholder",
"Currency": "uah"
}
}
name: Payment Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Run Unit Tests
run: dotnet test --filter "Category=Unit"
- name: Run Integration Tests
run: dotnet test --filter "Category=Integration"
env:
# Sandbox ключі зберігаються в GitHub Secrets
LiqPay__PublicKey: ${{ secrets.LIQPAY_SANDBOX_PUBLIC_KEY }}
LiqPay__PrivateKey: ${{ secrets.LIQPAY_SANDBOX_PRIVATE_KEY }}
# Sandbox тести (тільки на main branch)
- name: Run Sandbox Tests
if: github.ref == 'refs/heads/main'
run: dotnet test --filter "Category=Sandbox"
Надійна тест-стратегія для платіжного модуля будується з: unit-тестів (підписи, state machine), integration-тестів з Mock (бізнес-логіка без реальних PSP-викликів), sandbox-тестів (реальні виклики PSP у тестовому режимі) та contract-тестів (перевірка відповідності API контракту). Тестові картки дозволяють симулювати будь-який сценарій — success, decline, 3DS — без витрат реальних коштів.
Повернення коштів та диспути
Void, Refund, Chargeback — термінологія та практична реалізація повернень через LiqPay, Monobank та Stripe. Права споживачів в Україні та anti-fraud стратегії.
Чекліст виходу в Production
20 пунктів перевірки перед запуском платіжної системи в production — конфігурація, безпека, моніторинг, логування, юридична готовність.