Compare commits
5 commits
Author | SHA1 | Date | |
---|---|---|---|
a91dcb3a4a | |||
|
2f646c4131 | ||
3f4e9fdc92 | |||
8007f01d94 | |||
9b7a2870ff |
8 changed files with 179 additions and 53 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [5.0.0](https://git.kemitix.net/kemitix/kxio/compare/v4.0.1...v5.0.0) - 2024-12-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- *(net)* add tracing to matching each criteria for mock request
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- *(net)* [**breaking**] remove Drop assertions for any unused plans
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- *(print)* add details to readme and an example
|
||||||
|
|
||||||
## [4.0.1](https://git.kemitix.net/kemitix/kxio/compare/v4.0.0...v4.0.1) - 2024-12-25
|
## [4.0.1](https://git.kemitix.net/kemitix/kxio/compare/v4.0.0...v4.0.1) - 2024-12-25
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "kxio"
|
name = "kxio"
|
||||||
version = "4.0.1"
|
version = "5.0.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Paul Campbell <pcampbell@kemitix.net>"]
|
authors = ["Paul Campbell <pcampbell@kemitix.net>"]
|
||||||
description = "Provides injectable Filesystem and Network resources to make code more testable"
|
description = "Provides injectable Filesystem and Network resources to make code more testable"
|
||||||
|
|
30
README.md
30
README.md
|
@ -1,14 +1,15 @@
|
||||||
# kxio
|
# kxio
|
||||||
|
|
||||||
`kxio` is a Rust library that provides injectable `FileSystem` and `Network`
|
`kxio` is a Rust library that provides injectable `FileSystem`, `Network` and
|
||||||
resources to enhance the testability of your code. By abstracting system-level
|
`Print` resources to enhance the testability of your code. By abstracting
|
||||||
interactions, `kxio` enables easier mocking and testing of code that relies on
|
system-level interactions, `kxio` enables easier mocking and testing of code
|
||||||
file system and network operations.
|
that relies on file system and network operations.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Filesystem Abstraction**
|
- **Filesystem Abstraction**
|
||||||
- **Network Abstraction**
|
- **Network Abstraction**
|
||||||
|
- **Print Abstraction**
|
||||||
- **Enhanced Testability**
|
- **Enhanced Testability**
|
||||||
|
|
||||||
## Filesystem
|
## Filesystem
|
||||||
|
@ -30,6 +31,27 @@ The Network module offers a testable interface over the `reqwest` crate. For
|
||||||
comprehensive documentation and usage examples, please refer to
|
comprehensive documentation and usage examples, please refer to
|
||||||
<https://docs.rs/kxio/latest/kxio/net/>
|
<https://docs.rs/kxio/latest/kxio/net/>
|
||||||
|
|
||||||
|
## Print
|
||||||
|
|
||||||
|
The Print module provides three implementations of the `Printer` trait:
|
||||||
|
|
||||||
|
- `StandardPrint` - behaves as normal, printing to `STDOUT` and `STDERR`
|
||||||
|
- `NullPrint` - swallows all prints, outputting nothing
|
||||||
|
- `TestPrint` - captures all print output and makes it available for assertions in tests
|
||||||
|
|
||||||
|
It also provides macros to use with each:
|
||||||
|
|
||||||
|
- `kxprintln`
|
||||||
|
- `kxprint`
|
||||||
|
- `kxeprintln`
|
||||||
|
- `kxeprint`
|
||||||
|
|
||||||
|
They are analogous to the `std` macros: `println`, `print`, `eprintln` and `eprint` respectively.
|
||||||
|
|
||||||
|
Each of the `kx{e}print{ln}` macros takes a reference to an instance of the `Printer` trait as the first parameter.
|
||||||
|
|
||||||
|
For comprehensive documentation and usage examples, please refer to <https://docs.rs/kxio/latest/kxio/print/>
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Add `kxio` to your `Cargo.toml`:
|
Add `kxio` to your `Cargo.toml`:
|
||||||
|
|
|
@ -151,6 +151,8 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(contents, "contents");
|
assert_eq!(contents, "contents");
|
||||||
|
|
||||||
|
net.assert_no_unused_plans();
|
||||||
|
|
||||||
// not needed for this test, but should it be needed, we can avoid checking for any
|
// not needed for this test, but should it be needed, we can avoid checking for any
|
||||||
// unconsumed request matches.
|
// unconsumed request matches.
|
||||||
// let mock_net = kxio::net::MockNet::try_from(net).expect("recover mock");
|
// let mock_net = kxio::net::MockNet::try_from(net).expect("recover mock");
|
||||||
|
|
|
@ -32,15 +32,17 @@ struct Plan {
|
||||||
response: reqwest::Response,
|
response: reqwest::Response,
|
||||||
}
|
}
|
||||||
impl Plan {
|
impl Plan {
|
||||||
#[tracing::instrument]
|
#[tracing::instrument(skip(self))]
|
||||||
fn matches(&self, request: &Request) -> bool {
|
fn matches(&self, request: &Request) -> bool {
|
||||||
let url = request.url();
|
let url = request.url();
|
||||||
let is_match =
|
let is_match =
|
||||||
self.match_request.iter().all(|criteria| match criteria {
|
self.match_request.iter().all(|criteria| match criteria {
|
||||||
MatchRequest::Body(body) => {
|
MatchRequest::Body(body) => {
|
||||||
|
tracing::trace!(body = ?request.body(), "BODY");
|
||||||
request.body().and_then(reqwest::Body::as_bytes) == Some(body)
|
request.body().and_then(reqwest::Body::as_bytes) == Some(body)
|
||||||
}
|
}
|
||||||
MatchRequest::Header { name, value } => {
|
MatchRequest::Header { name, value } => {
|
||||||
|
tracing::trace!(%name, %value, "HEADER");
|
||||||
request
|
request
|
||||||
.headers()
|
.headers()
|
||||||
.iter()
|
.iter()
|
||||||
|
@ -51,13 +53,29 @@ impl Plan {
|
||||||
request_header_name.as_str() == name && request_header_value == value
|
request_header_name.as_str() == name && request_header_value == value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
MatchRequest::Method(method) => request.method() == http::Method::from(method),
|
MatchRequest::Method(method) => {
|
||||||
MatchRequest::Scheme(scheme) => url.scheme() == scheme,
|
tracing::trace!(%method, "METHOD");
|
||||||
MatchRequest::Host(host) => url.host_str() == Some(host),
|
request.method() == http::Method::from(method)
|
||||||
MatchRequest::Path(path) => url.path() == path,
|
}
|
||||||
MatchRequest::Fragment(fragment) => url.fragment() == Some(fragment),
|
MatchRequest::Scheme(scheme) => {
|
||||||
|
tracing::trace!(%scheme, "SCHEME");
|
||||||
|
url.scheme() == scheme
|
||||||
|
}
|
||||||
|
MatchRequest::Host(host) => {
|
||||||
|
tracing::trace!(%host,"HOST");
|
||||||
|
url.host_str() == Some(host)
|
||||||
|
}
|
||||||
|
MatchRequest::Path(path) => {
|
||||||
|
tracing::trace!(%path,"PATH");
|
||||||
|
url.path() == path
|
||||||
|
}
|
||||||
|
MatchRequest::Fragment(fragment) => {
|
||||||
|
tracing::trace!(%fragment,"FRAGMENT");
|
||||||
|
url.fragment() == Some(fragment)
|
||||||
|
}
|
||||||
MatchRequest::Query { name, value } => url.query_pairs().into_iter().any(
|
MatchRequest::Query { name, value } => url.query_pairs().into_iter().any(
|
||||||
|(request_query_name, request_query_value)| {
|
|(request_query_name, request_query_value)| {
|
||||||
|
tracing::trace!(%name, %value, "QUERY");
|
||||||
request_query_name.as_ref() == name && request_query_value.as_ref() == value
|
request_query_name.as_ref() == name && request_query_value.as_ref() == value
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -384,7 +402,7 @@ impl<'net> ReqBuilder<'net> {
|
||||||
let client = self.net.client();
|
let client = self.net.client();
|
||||||
// URL
|
// URL
|
||||||
let mut url = self.url;
|
let mut url = self.url;
|
||||||
tracing::trace!(?url);
|
tracing::trace!(%url);
|
||||||
// Query Parameters
|
// Query Parameters
|
||||||
if !self.query.is_empty() {
|
if !self.query.is_empty() {
|
||||||
url.push('?');
|
url.push('?');
|
||||||
|
@ -535,6 +553,7 @@ fn basic_auth_header_value(
|
||||||
pub struct MockNet {
|
pub struct MockNet {
|
||||||
plans: Rc<RefCell<Plans>>,
|
plans: Rc<RefCell<Plans>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockNet {
|
impl MockNet {
|
||||||
/// Helper to create a default [Client].
|
/// Helper to create a default [Client].
|
||||||
///
|
///
|
||||||
|
@ -599,6 +618,11 @@ impl MockNet {
|
||||||
tracing::debug!("reset plans");
|
tracing::debug!("reset plans");
|
||||||
self.plans.take();
|
self.plans.take();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the number of plans added and not yet matched against.
|
||||||
|
pub fn plans_left(&self) -> usize {
|
||||||
|
self.plans.borrow().len()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
impl From<MockNet> for Net {
|
impl From<MockNet> for Net {
|
||||||
fn from(mock_net: MockNet) -> Self {
|
fn from(mock_net: MockNet) -> Self {
|
||||||
|
@ -610,47 +634,6 @@ impl From<MockNet> for Net {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for MockNet {
|
|
||||||
#[cfg_attr(test, mutants::skip)]
|
|
||||||
#[tracing::instrument]
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// Don't assert during panic to avoid double panic
|
|
||||||
if std::thread::panicking() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let unused = self.plans.take();
|
|
||||||
if !unused.is_empty() {
|
|
||||||
log_unused_plans(&unused);
|
|
||||||
assert!(
|
|
||||||
unused.is_empty(),
|
|
||||||
"{} expected requests were not made",
|
|
||||||
unused.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Drop for Net {
|
|
||||||
#[cfg_attr(test, mutants::skip)]
|
|
||||||
#[tracing::instrument]
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// Don't assert during panic to avoid double panic
|
|
||||||
if std::thread::panicking() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(plans) = &self.plans {
|
|
||||||
let unused = plans.try_lock().expect("lock plans").take();
|
|
||||||
if !unused.is_empty() {
|
|
||||||
log_unused_plans(&unused);
|
|
||||||
assert!(
|
|
||||||
unused.is_empty(),
|
|
||||||
"{} expected requests were not made",
|
|
||||||
unused.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(test, mutants::skip)]
|
#[cfg_attr(test, mutants::skip)]
|
||||||
fn log_unused_plans(unused: &[Plan]) {
|
fn log_unused_plans(unused: &[Plan]) {
|
||||||
if !unused.is_empty() {
|
if !unused.is_empty() {
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
/// Macro to print the expression to the `Printer` with a trailing newline.
|
||||||
|
///
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! kxprintln {
|
macro_rules! kxprintln {
|
||||||
($printer:expr, $($arg:tt)*) => {{
|
($printer:expr, $($arg:tt)*) => {{
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
//!
|
//!
|
||||||
//! ```
|
//! ```
|
||||||
//! use kxio::print::{Print, StandardPrint};
|
//! use kxio::print::{Print, StandardPrint};
|
||||||
|
//! use kxio::{kxeprintln, kxprintln};
|
||||||
//!
|
//!
|
||||||
//! fn print_hello(printer: &impl Print) {
|
//! fn print_hello(printer: &impl Print) {
|
||||||
//! printer.println("Hello, World!");
|
//! printer.println("Hello, World!");
|
||||||
|
@ -18,6 +19,8 @@
|
||||||
//!
|
//!
|
||||||
//! let printer = StandardPrint;
|
//! let printer = StandardPrint;
|
||||||
//! print_hello(&printer);
|
//! print_hello(&printer);
|
||||||
|
//! kxprintln!(&printer, "Hello, world!");
|
||||||
|
//! kxeprintln!(&printer, "Terminating!");
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
mod macros;
|
mod macros;
|
||||||
|
|
100
tests/net.rs
100
tests/net.rs
|
@ -53,6 +53,52 @@ async fn test_get_url() {
|
||||||
assert_eq!(response.bytes().await.expect("response body"), "Get OK");
|
assert_eq!(response.bytes().await.expect("response body"), "Get OK");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_when_all_plans_match_assert_is_ok() {
|
||||||
|
//given
|
||||||
|
let mock_net = kxio::net::mock();
|
||||||
|
|
||||||
|
let url = "https://www.example.com";
|
||||||
|
|
||||||
|
mock_net
|
||||||
|
.on()
|
||||||
|
.get(url)
|
||||||
|
.respond(StatusCode::OK)
|
||||||
|
.body("Get OK")
|
||||||
|
.expect("mock");
|
||||||
|
let net = Net::from(mock_net);
|
||||||
|
|
||||||
|
//when
|
||||||
|
let _response = net.get(url).send().await.expect("response");
|
||||||
|
|
||||||
|
//then
|
||||||
|
net.assert_no_unused_plans();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[should_panic]
|
||||||
|
async fn test_when_not_all_plans_match_assert_fails() {
|
||||||
|
//given
|
||||||
|
let mock_net = kxio::net::mock();
|
||||||
|
|
||||||
|
let url = "https://www.example.com";
|
||||||
|
|
||||||
|
mock_net
|
||||||
|
.on()
|
||||||
|
.get(url)
|
||||||
|
.respond(StatusCode::OK)
|
||||||
|
.body("Get OK")
|
||||||
|
.expect("mock");
|
||||||
|
let net = Net::from(mock_net);
|
||||||
|
|
||||||
|
//when
|
||||||
|
// request is not made
|
||||||
|
// let _response = net.get(url).send().await.expect("response");
|
||||||
|
|
||||||
|
//then
|
||||||
|
net.assert_no_unused_plans();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_post_url() {
|
async fn test_post_url() {
|
||||||
//given
|
//given
|
||||||
|
@ -642,3 +688,57 @@ async fn test_get_with_user_agent() {
|
||||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
assert_eq!(valid.status(), StatusCode::OK);
|
assert_eq!(valid.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reset_removes_all_plans() {
|
||||||
|
//given
|
||||||
|
let mock_net = kxio::net::mock();
|
||||||
|
let url = "https://www.example.com/path";
|
||||||
|
|
||||||
|
mock_net
|
||||||
|
.on()
|
||||||
|
.get(url)
|
||||||
|
.user_agent("007")
|
||||||
|
.respond(StatusCode::OK)
|
||||||
|
.mock()
|
||||||
|
.expect("mock");
|
||||||
|
mock_net
|
||||||
|
.on()
|
||||||
|
.get(url)
|
||||||
|
.user_agent("orange")
|
||||||
|
.respond(StatusCode::FORBIDDEN)
|
||||||
|
.mock()
|
||||||
|
.expect("mock");
|
||||||
|
assert_eq!(mock_net.plans_left(), 2);
|
||||||
|
|
||||||
|
//when
|
||||||
|
mock_net.reset();
|
||||||
|
|
||||||
|
//then
|
||||||
|
assert_eq!(mock_net.plans_left(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn try_from_with_net_from_a_mock_net() {
|
||||||
|
//given
|
||||||
|
let mock_net = kxio::net::mock();
|
||||||
|
let net = Net::from(mock_net);
|
||||||
|
|
||||||
|
//when
|
||||||
|
let result = MockNet::try_from(net).await;
|
||||||
|
|
||||||
|
//then
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn try_from_with_net_not_from_a_mock_net() {
|
||||||
|
//given
|
||||||
|
let net = kxio::net::new();
|
||||||
|
|
||||||
|
//when
|
||||||
|
let result = MockNet::try_from(net).await;
|
||||||
|
|
||||||
|
//then
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue